214 KiB
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 comgerencia_permissoes=True(acesso total + gerencia Perfis de Acesso/Usuários) e o único com os*-editardelinks-ferramentas(cartões e Acessos Gerais) e deramaisTrue(único perfil que pode reordenar/incluir/remover cartões em Links & Ferramentas, criar/editar seções e acessos em Acessos Gerais, e adicionar/editar ramal/criar ausência em Ramais, até que outro perfil seja liberado em Perfis de Acesso — ver "Modelo de permissões" abaixo).bruno/bruno— sem perfil vinculado, usado para testar o estado "sem acesso".
Não há suíte de testes, lint ou build configurados neste projeto.
Ambiente de desenvolvimento assistido
O .venv do projeto já tem Python 3.13 + Django 6.0 + DRF + psycopg + python-dotenv + Pillow + nh3 + reportlab + openpyxl + holidays + docling instalados, e há um Postgres local acessível via .env — dá pra rodar makemigrations/migrate/seed_portal/runserver normalmente por aqui usando .venv\Scripts\python.exe manage.py ... (ou ativando o venv primeiro). Isso deixou de ser uma limitação a partir da rodada em que o ambiente ganhou essas ferramentas (ver plano.md) — não assumir mais que só é possível revisar o backend estaticamente.
Arquitetura
Estrutura de pastas (padrão Django)
Portal/
├── manage.py
├── requirements.txt
├── .env
├── config/ # settings.py, urls.py, wsgi.py, asgi.py — pacote de configuração do projeto
├── portal_api/ # único app Django (models, serializers, views, admin, migrations, seed)
├── templates/ # as 8 páginas HTML (TEMPLATES[0]["DIRS"] em settings.py aponta pra cá)
├── static/ # css/, js/, img/ — STATICFILES_DIRS em settings.py aponta pra cá
├── media/ # upload de usuário (hoje só ícones de LinkFerramenta) — MEDIA_ROOT em settings.py
├── CLAUDE.md
└── plano.md
Logos em static/img/
Duas identidades visuais coexistem de propósito hoje: o logo cursivo "D De Paula Contadores" (usado só nos documentos/PDFs que a aplicação gera, ver abaixo) e a marca nova "P.I.D." (.svg, ver pid-marca-leiame.md em C:\Users\Depaula\Documents\Projetos\LOGOS\pid-marca), adotada no favicon, no login e na sidebar do Portal. Essa separação é deliberada, não uma migração incompleta: um documento gerado pela aplicação (contrato, procuração, recibo do Indicador de Desempenho, PDF da Simulação de Custo de Contratação) é emitido como se o próprio escritório o tivesse gerado — carrega a identidade do escritório perante o cliente, não a identidade do Portal como ferramenta interna. A marca "P.I.D." é só pra UI do Portal em si. Não migrar o logo de um gerador de documento pra "P.I.D." (nem vice-versa numa tela do Portal) sem confirmar de novo com o usuário.
Logo cursivo "D De Paula Contadores" (D em degradê dourado/marrom + texto, PNG com fundo transparente) — não aparece em nenhum template HTML hoje, só nos PDFs gerados pela aplicação:
logo.png— original, texto preto. Serve como fonte pra gerar as outras variantes e é usada diretamente no recibo do Indicador de Desempenho (indicadores/recibo.py,LOGO_PATH, redimensionada/recomprimida em memória pra impressão — ver "Indicador de Desempenho" abaixo).logo-branco.png— usada no cabeçalho do PDF de Simulação de Custo de Contratação (custo_contratacao/pdf.py, banner marrom escuro, ver "Simulação de Custo de Contratação" abaixo) — até uma rodada anterior também era usada nosidebar__branddos 10 shells, migrada pra marca "P.I.D." (ver abaixo; a UI do Portal e os documentos gerados usam fontes de logo independentes agora). Mesmo D colorido delogo.png, mas com o texto recolorido pra branco; gerada programaticamente a partir delogo.png(script Python com Pillow: qualquer pixel opaco quase-neutro/escuro —max(r,g,b) < 70espread(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, regerarlogo-branco.pnga partir do novologo.pngcom o mesmo filtro, não editar à mão.logo-mono.png— versão totalmente monocromática (D e texto em branco/cinza claro). Não usada em nenhum consumidor hoje; existe como variante alternativa (útil se algum dia precisar de um logo "chapado" sem o dourado do D).
Marca nova "P.I.D.":
pid-icone.svg— ícone quadrado, variante pra fundo claro (corpo roxo escuro#4B2E75); chegou a ser usada no login quandodata-theme="light", mas o usuário pediu pra usar sempre a mesma logo nos dois temas — não tem mais nenhum consumidor hoje (mesma situação depid-favicon.svg/pid-logo-horizontal.svgabaixo).pid-icone-escuro.svg— ícone quadrado, variante pra fundo escuro (corpo roxo mais claro#7B5BA8, pra manter contraste); usada (a) no favicon (<link rel="icon" type="image/svg+xml">no<head>das 11 páginas) e (b) no login, sempre, nos dois temas — o card do login já é congelado escuro nos dois temas (ver "Card de login" abaixo), e agora o ícone também, pedido explícito do usuário ("deixe no tema claro a mesma logo usada no tema escuro"; antes alternava compid-icone.svgconformedata-theme, mecanismo removido — ver "Ícone do login é clicável" abaixo). O ícone da sidebar (expandida e colapsada, 10 shells) e o do card de login usam o mesmo desenho/cores desse arquivo, mas como markup<svg>inline copiado direto no HTML, não uma referência a este arquivo — ver "Ícone do login/sidebar são clicáveis" abaixo pro motivo (precisa expor os olhos pro CSS/JS animar o piscar ao clicar).pid-logo-horizontal-escuro.svg— assinatura horizontal (ícone + "P.I.D." + "PORTAL INTERNO DA DE PAULA" em texto, viewBox300×80, texto claro#F2EDE3/dourado#C6A24A— variante pra fundo escuro). Mesmo caso do ícone acima: a sidebar expandida (10 shells) usa o mesmo desenho como<svg>inline, não uma referência a este arquivo.pid-favicon.svg(versão simplificada sem o sorriso, pro leiame recomendar pra 16–24px — não usada, o favicon usapid-icone-escuro.svgmesmo),pid-logo-horizontal.svg(variante fundo claro da assinatura) epid-icone.svg(acima) não têm nenhum consumidor no Portal hoje.
sidebar__brand com duas marcas sobrepostas (crossfade), não uma redimensionada (layout.css): a sidebar expandida mostra a assinatura com texto (tem texto, ilegível se só encolhida) e a colapsada mostra só o símbolo — são dois <svg class="sidebar__logo sidebar__logo--full">/<svg class="sidebar__logo sidebar__logo--icon"> sempre presentes no DOM, sobrepostos via position:absolute dentro de .sidebar__brand (position:relative; height:92px, alto o bastante pra caber as duas sem depender da altura natural de nenhuma, já que filhos absolutos não contribuem pra altura do pai). .sidebar__logo--full é width:250px; max-width:96% (quase toda a largura útil da sidebar, 264px menos o padding horizontal de .sidebar) — aumentado em duas rodadas a partir do tamanho inicial (176px/70% → 220px/92% → 250px/96%) porque o subtítulo "PORTAL INTERNO DA DE PAULA" (fonte pequena dentro do SVG, viewBox 300×80) ficava ilegível menor; height:92px acompanhou cada aumento pra sobrar espaço vertical (proporção do SVG é 300:80, então a altura renderizada escala junto com a largura). A transição entre elas é opacity+transform:scale() (var(--transition-base), mesma duração das outras animações do sidebar) — pedido explícito do usuário pra não ser uma troca brusca; .app-shell.is-collapsed (toggle desktop) e o breakpoint mobile (@media (max-width:1024px), onde o colapsado é o estado default e .is-expanded-mobile o inverte, mesmo padrão já usado pelos demais elementos do menu nesse breakpoint) alternam qual das duas fica com opacity:1.
Ícone do login e da sidebar são clicáveis, com os olhos piscando (pedido explícito do usuário, em duas rodadas — primeiro a sidebar, depois o ícone do login): tanto .sidebar__brand (10 shells) quanto #login-logo-btn (index.html) tiveram o ícone convertido de <img src="...svg"> pra <svg> inline, com markup idêntico ao de pid-icone-escuro.svg copiado direto no HTML — um <img> não expõe seu conteúdo interno pro CSS/JS da página (é uma imagem opaca), então não dava pra animar só os olhos sem inlinear. Dentro de cada SVG, os dois olhos (círculo creme + glint escuro) ficam num <g class="pid-icon-eye"> próprio, sem nenhum transform no XML (a posição já vem dos cx/cy dos círculos) — importante porque um transform de CSS aplicado num elemento que já tem um transform de atributo substitui o atributo inteiro (perderia a posição); mantendo os dois olhos "limpos" desse jeito, a única transformação deles é a que a animação de piscar aplica. .pid-icon-eye/.is-blinking/@keyframes pidIconBlink moram em base.css (transform-box:fill-box; transform-origin:center faz o scaleY() girar em torno do próprio olho, não da origem do SVG; scaleY(1)→0.05→1, 200ms, os dois olhos piscam juntos) — em base.css, não em layout.css, porque é carregado por todas as páginas, inclusive index.html (que não carrega layout.css).
- Sidebar (
static/js/sidebar-brand.js, incluído logo depois deapi.jsnos 10 shells):.sidebar__branddeixou de ser<div>e virou<a href="portal.html" id="sidebar-brand-link" aria-label="Ir para a tela Principal">— a mudança de tag não afeta o CSS existente (todo seletor é por classe), então o crossfade descrito acima continua igual. O script escuta o clique: ignora cliques modificados (ctrl/cmd/shift/botão do meio — deixa abrir em nova aba normalmente), senão fazpreventDefault(), adiciona.is-blinkingem todo.pid-icon-eyedentro do link (pega os olhos das duas marcas — a visível e a escondida pelo crossfade, inofensivo já que a escondida temopacity:0) e só navega praportal.htmldepois de 260ms, tempo suficiente pra piscada terminar de tocar antes da página trocar — mesmo espírito de "deixar a transição ser vista antes de navegar" já usado na animação de intro do login. - Login (
static/js/login-logo-blink.js, novo):#login-logo-btné um<button type="button">(não um link — não há pra onde navegar a partir do próprio login), sempreventDefault/delay nenhum, só dispara a piscada ao clicar; é puramente decorativo, sem efeito colateral. Também aqui foi a oportunidade de simplificar um mecanismo que existia só por causa do<img>: a logo do login alternava entrepid-icone.svg(claro)/pid-icone-escuro.svg(escuro) conformedata-theme, viapidSyncLoginLogo()emtheme.js— removida junto com essa mudança, a pedido do usuário ("deixe no tema claro a mesma logo usada no tema escuro"), já que o card do login já era congelado escuro nos dois temas mesmo antes disso (ver "Card de login" abaixo) — a lógica de alternância nunca fazia muito sentido nesse contexto. Agora#login-logo-btnsempre usa as cores/desenho depid-icone-escuro.svg, sem nenhuma checagem de tema.
Backend serve o frontend (mesma origem)
config/urls.py registra path("api/", include("portal_api.urls")) e, para cada página HTML do frontend (index.html, portal.html, perfis-acesso.html, usuarios.html, calendario-individual.html, links-ferramentas.html, acessos-gerais.html, ramais.html), uma rota TemplateView que resolve o arquivo em templates/. Os estáticos (static/css, static/js, static/img) são servidos por django.contrib.staticfiles automaticamente em DEBUG (via STATICFILES_DIRS) — não há mais nenhum re_path/static_serve manual em urls.py. Cada template usa {% load static %} + {% static 'css/tokens.css' %} (nunca um caminho hardcoded tipo assets/css/..., que não existe mais). Essa escolha (Django servindo o próprio frontend) existe para evitar CORS/cookie cross-origin: autenticação é por sessão/cookie do Django, então frontend e API precisam estar na mesma origem.
Em produção, rodar python manage.py collectstatic (junta tudo em STATIC_ROOT = BASE_DIR / "staticfiles") e servir esse diretório via whitenoise/nginx — django.contrib.staticfiles só serve automaticamente quando DEBUG=True. Uploads de usuário (ícones de LinkFerramenta) são um mecanismo separado: MEDIA_URL/MEDIA_ROOT em settings.py, servidos por config/urls.py via static() só quando DEBUG=True (em produção, servir media/ também por whitenoise/nginx, igual ao STATIC_ROOT).
Apps Django
Um único app, portal_api/:
| Arquivo | Conteúdo |
|---|---|
models.py |
Usuario (AbstractUser + nome, M2M perfis, M2M departamentos (pra Departamento, ver abaixo), campos cadastrais opcionais codigo_folha/codigo_questor/codigo_tareffa/codigo_contabit/ramal (CharField, blank=True) e data_aniversario (DateField, null=True, blank=True), lideranca (BooleanField, é gerente/coordenador) e M2M liderados (self-referential, symmetrical=False, related_name="lideres" — ver seção "Liderança" abaixo) — email já vem de AbstractUser, não precisou de campo novo; método permissao_app(module_key, app_key) — união genérica de um flag de apps entre os perfis vinculados), 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; também realinha a sequence do Postgres por trás de PerfilAcesso.codigo (ver nota abaixo). |
management/commands/seed_indicador_desempenho.py |
Popula o primeiro histórico do Indicador de Desempenho (7 IndicadorCriterio + 5 IndicadorPercentualTipo, idempotente) com os valores da planilha antiga — ver seção "Indicador de Desempenho" abaixo. |
planos_saude/ |
Pacote Python puro (sem ORM) com o pipeline de extração/casamento de "Importação de Plano de Saúde", portado de projects/project/ — ver seção própria abaixo. |
custo_contratacao/ |
Pacote Python puro (sem ORM) da ferramenta "Simulação de Custo de Contratação" (Geradoc) — tabelas.py (seed/default das faixas de INSS/IRRF, hoje editáveis via ParametroFiscalCustoContratacao), calculo.py (ParametrosFiscais dataclass + calcula_custo_empregado), pdf.py (gera_pdf_simulacao, via reportlab). Ver seção própria abaixo. |
indicadores/ |
Pacote Python puro (sem ORM) da ferramenta "Indicador de Desempenho" (Geradoc) — tipos.py (deriva o tipo de colaborador por empresa via Tareffa), leiaute.py (leitura das planilhas Tareffa/Honorários via openpyxl), pipeline.py (orquestração, processa_apuracao), entregas.py (cálculo dos 3 critérios automáticos), calculo.py (composição dos percentuais Individual/Grupo/Departamento e valores em R$), recibo.py (PDF do recibo por colaborador, via reportlab). Ver seção própria abaixo. |
API (sessão + CSRF, não token)
| Endpoint | Método | Uso |
|---|---|---|
/api/auth/csrf/ |
GET | garante o cookie csrftoken |
/api/auth/login/ |
POST | {username, password} → cria sessão |
/api/auth/logout/ |
POST | encerra sessão |
/api/me/ |
GET | usuário logado + perfis + departamentos (os próprios, pra alimentar o seletor de "Meu departamento" do Calendário Individual) + gerencia_permissoes + 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 → dual-select.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ..., seguido de scripts específicos da página. Nenhum script usa defer exceto theme.js (que fica no <head>).
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"edata-section="usuarios"(dentro de Administração) são gateados porgerencia_permissoes, não porpermissoes_efetivas.administracao.enabled.- O selo "Restrito" ao lado de Relatórios Gerenciais (
[data-restricted-badge]) é mostrado se algum perfil vinculado tivernome === "Diretoria"— um match de nome fixo, não uma flag de permissão.
Padrão visualizar/editar (Links & Ferramentas, Acessos Gerais, Ramais e futuros módulos parecidos)
Alguns módulos não são só "lista de aplicações que aparecem ou não no menu" — têm uma ou mais telas próprias com conteúdo administrável (adicionar/remover/reordenar), que pedem dois níveis de acesso, não um: visualizar (ver a tela) e editar (mudar o conteúdo dela). Em vez de criar um BooleanField dedicado por módulo (o que foi tentado numa rodada e revertido — não escala pra "várias aplicações com a mesma funcionalidade"), o padrão adotado é modelar cada aplicação como um subgrupo com dois tools (visualizar/editar) dentro do módulo em catalogo.MODULE_APPS — mesmo formato de subgrupo já usado em Auditorias/Ramais:
"links-ferramentas": [
{
"key": "links-ferramentas-cartoes",
"label": "Links & Ferramentas",
"tools": [
{"key": "links-ferramentas-visualizar", "label": "Visualizar"},
{"key": "links-ferramentas-editar", "label": "Editar (reordenar, incluir e remover cartões)"},
],
},
{
"key": "acessos-gerais",
"label": "Acessos Gerais",
"tools": [
{"key": "acessos-gerais-visualizar", "label": "Visualizar"},
{"key": "acessos-gerais-editar", "label": "Editar (criar/editar/excluir seções e acessos)"},
],
},
],
links-ferramentas nasceu com só um par visualizar/editar flat (sem subgrupo, já que só existia uma aplicação na seção); virou dois subgrupos quando "Acessos Gerais" foi adicionado como uma segunda aplicação dentro da mesma seção — a mesma evolução que ramais já tinha passado antes (ver "Navegação por abas em ramais.html" abaixo). Cada chave de tool é prefixada com o nome da aplicação (links-ferramentas-visualizar, não só visualizar) porque permissoes[module_key]["apps"] é um dict achatado — todas as tools de todos os subgrupos do módulo compartilham o mesmo namespace, então chaves genéricas colidiriam entre as duas aplicações.
Isso reaproveita 100% a árvore de permissões que já existe (renderTree()/renderEntry()/renderLeaf() em profiles.js, sem nenhum código de UI novo) — na tela de edição de perfil, "Links & Ferramentas" aparece expansível com "Links & Ferramentas" e "Acessos Gerais" como subgrupos, cada um expansível de novo em "Visualizar"/"Editar", do mesmo jeito que "Auditorias" mostra "Consultoria Tributária" → "Controle Simples Nacional". No backend, a checagem usa Usuario.permissao_app(module_key, app_key) (união entre os perfis vinculados, mesma lógica de permissoes_efetivas()) e a classe genérica PermissaoApp(module_key, app_key) em permissions.py, instanciada por view — nenhuma subclasse nova é necessária pra outro módulo/aplicação adotar o mesmo padrão, só instanciar com outra app_key.
Cuidado com seed_portal.py: o módulo continua tendo seu próprio enabled (visibilidade no menu) e, se estiver em BASE_KEYS/SECTORAL_KEYS etc., catalogo.permissions_from_keys() habilita todos os apps de um módulo enabled de uma vez — incluindo todo *-editar. Por isso seed_portal.py força permissoes["links-ferramentas"]["apps"]["links-ferramentas-editar"] = False, permissoes["links-ferramentas"]["apps"]["acessos-gerais-editar"] = False e permissoes["ramais"]["apps"]["ramais-editar"] = False (+ as outras duas subtelas administráveis de Ramais) explicitamente pra todo perfil que não seja "Integração e Inovação" (código 8, chaves=None → todos os apps True de propósito), depois de gerar o dict — sem esse override, qualquer perfil com o módulo habilitado nasceria com poder de editar tudo. Ao adicionar uma aplicação nova nesse padrão (dentro de um módulo existente ou não), replicar esse mesmo cuidado no seed.
Bug real (rodada 69) — IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey" ao criar um perfil pela tela: seed_portal.py semeia PerfilAcesso com codigo explícito (update_or_create(codigo=dado["codigo"], ...), já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: codigo == 8 = "Integração e Inovação"). No Postgres, um INSERT com PK explícita nunca avança a sequence por trás do AutoField — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (POST /api/perfis/, sem PK explícita) recebia codigo=1 do nextval(), colidindo com um código já usado pelo seed. _reset_sequence(model) (função módulo-level em seed_portal.py, roda SELECT setval(pg_get_serial_sequence(...), MAX(pk)) via SQL puro do Postgres) corrige isso, chamada logo depois do loop de PERFIS_SEED — toda vez que seed_portal roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um loaddata/RunPython de migração também inserindo PerfilAcesso com PK explícita sem passar por seed_portal.py depois), o comando pra corrigir manualmente é python manage.py seed_portal (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis)
Botão #pa-app-search-btn ao lado de "Novo Perfil" (perfis-acesso.html) abre #pa-app-search-modal — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia todos os perfis que têm acesso a ela numa tabela só.
- Fonte dos dados — 100% reaproveitado, sem endpoint novo:
pidFlattenAplicacoes()(profiles.js) achataPID_MODULES×PID_MODULE_APPS(já carregados deGET /api/catalogo/no load da página) numa lista plana de "aplicações" — uma por entrada deMODULE_APPS, seja ela um app simples ou um subgrupo comtools(mesma unidade usada emrenderEntry()da árvore). A busca (renderAppSearchResults()) casa o termo contra o label da aplicação, o label do módulo e o label de cadatoolaninhada — esse último é o que permite achar, por exemplo, "Controle Simples Nacional" (otoolreal 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 portool— para app simples sem subgrupo, uma coluna única "Acesso". O cabeçalho de cada coluna usatool.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
changedisparaPATCH /api/perfis/{codigo}/só com{permissoes: perfil.permissions}(pidUpdatePerfilPermissoes, PATCH parcial — oModelViewSetjá aceita,PerfilAcessoSerializernão exige os outros campos fora departial_update). Erro de rede reverte o checkbox e o estado em memória, com umalert()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=falsenaquele perfil, o toggle também viraperm.enabled = trueno mesmo PATCH — sem isso, o perfil ganharia a chave emappsmas o item continuaria escondido no menu (access.jsesconde onav-group/nav-subiteminteiro porenabled, 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" deusuarios.html), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles.
Aba "Usuários do Escritório" (dentro da edição de um perfil) — duas tabelas com seleção múltipla
Substituiu o antigo <select> + botão "Vincular" + lista simples com X pra remover — pedido explícito do usuário pra reestruturar visualmente no estilo de um componente de transferência dupla (referência: uma tela de outro sistema com duas grades lado a lado, cada uma com checkbox de seleção, busca por coluna, ordenação e um botão de ação em lote).
- Duas tabelas (
.pa-users-dual, grid 2 colunas que colapsa pra 1 abaixo de 900px): à esquerda,#pa-users-available-*— todo usuário ativo ainda não vinculado a este perfil; à direita,#pa-users-linked-*— todo usuário ativo já vinculado. Usuário inativo nunca aparece em nenhum dos dois painéis (listaParaPainelUsuarios()filtrausuariosCacheAtualporis_activeantes de separar entre vinculado/disponível) — decisão explícita do usuário; na prática, inativar já limpa osperfisde alguém no backend (_revogar_acesso_se_inativo(), ver "Inativar/reativar usuário" abaixo), então esse filtro no frontend é sobretudo defensivo pra dados legados. Cada tabela tem: checkbox de seleção por linha + "selecionar todos" no cabeçalho (#pa-users-available-select-all/#pa-users-linked-select-all, aplica só sobre as linhas filtradas visíveis, mesmo critério de.checklist-select-all), coluna "Nome Usuário" ordenável (clique alterna asc/desc, ícone↕que fica--accentquando ativo — mesmo padrão.ua-sort-iconjá usado emusuarios.html) e coluna "E-mail" (não ordenável), com uma segunda linha de cabeçalho (.pa-users-table__filters) só com os campos de busca por nome/e-mail — filtro client-side sobre o array já carregado, sem debounce. - Botão de atualizar (ícone circular,
#pa-users-available-refresh-btn/#pa-users-linked-refresh-btn) refazGET /api/usuarios/(refreshUsuarios(),profiles.js) e re-renderiza os dois painéis a partir do mesmo cache — as duas tabelas sempre refletem o mesmo snapshot de usuários, nunca buscam independentemente uma da outra. - Vincular/Desvincular em lote: o botão de cada painel (
#pa-users-link-btn/#pa-users-unlink-btn, desabilitado enquanto a seleção daquele painel estiver vazia) disparabulkAlterarVinculo(kind, vincular)— umPATCH /api/usuarios/{id}/(pidSetUsuarioPerfis) por usuário selecionado, em paralelo (Promise.all), cada um recalculando a própria lista deperfis(adiciona ou remove só ocodigodo perfil sendo editado, preservando os demais perfis do usuário). Ao terminar, a seleção é limpa e os dois painéis são recarregados do zero (refreshUsuarios()) — um usuário que acabou de ser vinculado desaparece da tabela da esquerda e aparece na da direita, e vice-versa. - Abrir a aba de um perfil diferente (
renderUsers(), chamada poropenEdit()) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior. - Sem endpoint novo — 100% reaproveitamento de
GET /api/usuarios/(UsuarioListSerializer, já expõeemail) ePATCH /api/usuarios/{id}/(pidSetUsuarioPerfis, já existia).
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 emlogin_view) roda viadjango.contrib.auth.backends.ModelBackend, que internamente chamauser_can_authenticate()e recusa (None) qualquer usuário comis_active=False, mesmo com a senha certa. Como isso fazauthenticate()retornarNonetanto pra senha errada quanto pra usuário inativo,login_viewfaz 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 popularrequest.usera partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário viraAnonymousUserautomaticamente eIsAuthenticated/PodeGerenciarPermissoesjá barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login.
Inativar desvincula perfis de acesso e liderança automaticamente (_revogar_acesso_se_inativo(), serializers.py, decisão explícita do usuário): sempre que UsuarioSerializer.create()/update() termina com usuario.is_active=False, perfis é limpo (usuario.perfis.clear()) e o usuário sai do liderados de qualquer gerente que o tivesse (usuario.lideres.clear() — lideres é a relação reversa de Usuario.liderados; limpar aqui remove usuario do lado de quem o lidera, sem afetar quem usuario eventualmente lidera, caso ele mesmo seja gerente). A chamada é sempre a última coisa em create()/update(), depois dos .set() de perfis/departamentos/liderados — colocar antes seria inútil, já que o formulário de edição de usuarios.html sempre reenvia o checklist de perfis inteiro junto com qualquer mudança no checkbox "Usuário ativo", e um .set() posterior desfaria uma limpeza feita cedo demais. Cobre os dois pontos de entrada reais (botão de alternar na lista + checkbox no formulário de edição), ambos passando por PATCH /api/usuarios/{id}/; não há um hook equivalente no admin.py (uso interno, fora de escopo). Não é uma trava: nada impede reativar alguém depois (ele volta sem nenhum perfil/liderança, precisa reconfigurar) nem impede — por ora — que um gerente adicione manualmente um usuário já inativo aos próprios liderados pela tela dele (o hook só dispara ao salvar o usuário inativo em si, não ao salvar o gerente).
Na UI (users-admin.js/usuarios.html): coluna "Status" na lista (.status-pill/.status-pill--ativo/.status-pill--inativo, mesmo componente que já existia pro PerfilAcesso.ativo) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — PATCH /api/usuarios/{id}/ com {is_active: !atual}, com confirmação via window.confirm. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam auto-desativação (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, id === me.id) — no formulário isso aparece como o checkbox desabilitado (disabled) quando account.id === me.id, em vez de um alerta.
Filtro de status na lista (#ua-status-filtros, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de .ind-departamento-chip): client-side, sobre o array users já carregado (statusFiltro em users-admin.js, aplicado em renderList() antes do filtro de busca por texto). Nasce em "ativos" por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra "ativos" só no load da página, não a cada renderList().
Colunas de código cadastral + ordenação (#ua-table): a lista também mostra codigo_folha/codigo_questor/codigo_tareffa/codigo_contabit/ramal como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (blank=True) no model. Célula vazia renderiza <span class="ua-campo-vazio">—</span> (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo <th data-sort="..."> (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (sortKey/sortDir em users-admin.js, ícone ↕ que fica --accent quando ativo) — mesmo padrão de #ips-list-table (importacao-plano-saude.js) e do modal de Ramais (ramais-lookup.js), inclusive a mesma função de comparação (comparaValoresUsuario, número vs. número quando os dois convertem, senão localeCompare pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em api.js. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: codigo_tareffa via a view já existente em portal_api/database/ que lê o Tareffa, ver project_database_package na memória) em vez de digitados à mão, mas essa vinculação de verdade não foi implementada nesta rodada — só a estrutura visual. Cada um dos 3 campos (#ua-codigo-folha/#ua-codigo-questor/#ua-codigo-tareffa) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (.ua-field-link — borda/raio únicos, botão separado por border-left, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre disabled com title="Vinculação com sistema externo ainda não implementada" — não tem nenhum handler de clique em users-admin.js. codigo_contabit e ramal (também campos cadastrais na mesma seção "Dados Cadastrais") não ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o disabled, adicionar o handler), não recriar o campo do zero.
Liderança (gerente/coordenador) e o modal "Gerenciar Usuário"
Usuario.lideranca (booleano) marca um usuário como gerente/coordenador de outros; Usuario.liderados é um M2M auto-referenciado ("self", symmetrical=False, related_name="lideres") — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar lideranca=True em "debora" e incluir "gabriel" em liderados representa "debora é gerente de gabriel".
Dois lugares gravam essa mesma relação:
- Tela de Usuários (
usuarios.html/users-admin.js, só quem temgerencia_permissoes): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (#ua-lideranca) libera (hidden) o widget de vinculação dual descrito abaixo (a própria conta sendo editada é excluída da lista de candidatos — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envialideranca+liderados(array de ids) no mesmo payload dePATCH/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-modaljá existente, mesmo fluxo de antes) e, só seme.liderancafortrue, o mesmo widget de vinculação dual — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem deGET /api/usuarios-resumo/(não de/api/usuarios/, que exigegerencia_permissoes) e salvar disparaPATCH /api/me/liderados/, que grava na mesmaUsuario.liderados—meus_liderados_viewrecusa (403) serequest.user.liderancaforFalse, já que só faz sentido pra quem tem o checkbox marcado.
Widget "Usuários sob liderança" — duas tabelas, não vinculados à esquerda e vinculados à direita (.dual-select, static/js/dual-select.js + estilos em components.css): substituiu o antigo .checklist-box de uma lista só (checkbox + busca + "marcar todos") — reestruturado a pedido do usuário no mesmo estilo do widget "Usuários do Escritório" de Perfis de Acesso (ver seção própria abaixo), só que genérico o bastante pra rodar tanto em usuarios.html quanto dentro do modal "Gerenciar Usuário" (presente em todo shell). pidCriarSeletorDuplo(config) (dual-select.js, incluído no prefixo de scripts de todo shell, logo depois de api.js) é a fábrica compartilhada — recebe as referências de DOM de cada painel (available/linked: checkbox "selecionar todos", cabeçalho ordenável, dois campos de busca, corpo da tabela, rodapé de contagem e o botão de ação) mais secundariaValor(candidato) (aqui, departamentosTexto(), unindo os nomes dos departamentos por vírgula) e devolve { setDados(candidatos, vinculadosIniciais), getVinculadosIds() }. Cada tela (users-admin.js/account.js) só chama setDados() ao abrir o formulário/modal e getVinculadosIds() no momento de salvar — a vinculação em si é só em memória dentro do widget (nenhuma chamada de API própria), o "Vincular"/"Desvincular" só move ids entre os dois painéis local mente, igual ao checklist antigo (que também só populava um Set em memória até o "Salvar" de fora).
- Cada painel tem: checkbox de seleção múltipla + "selecionar todos" (sobre as linhas filtradas visíveis, mesmo critério do antigo
.checklist-select-all), coluna "Nome" ordenável (clique alterna asc/desc) e coluna "Departamento", com uma segunda linha de cabeçalho só com os dois campos de busca (por nome e por departamento, independentes) — filtro client-side sobre o array já carregado. O botão de ação do painel ("Vincular" a esquerda/"Desvincular" a direita) fica desabilitado enquanto a seleção daquele painel estiver vazia, e mover usuários limpa a seleção e re-renderiza os dois painéis (quem saiu de um painel aparece no outro). #manage-account-modal-card(id novo no.modal-carddo modal "Gerenciar Usuário") ganha a classe.modal-card--widevia JS (account.js) só quandome.liderancaétrue— o modal volta ao tamanho padrão (420px) quando só tem o botão "Alterar senha", em vez de ficar largo à toa pra quem não lidera ninguém..dual-select/.dual-select__*moram emcomponents.css(não emperfis-acesso.css), pela mesma razão de.checklist-box— o modal "Gerenciar Usuário" existe em todo shell, e a maioria deles não carregaperfis-acesso.css.- Usuário inativo nunca aparece nos candidatos — mesma decisão de "Usuários do Escritório" acima. Em
usuarios.html,fillLideradosChecklist()(users-admin.js) filtrausersporis_activeantes de montar a lista de candidatos; no modal "Gerenciar Usuário", isso já vem de graça porqueGET /api/usuarios-resumo/(usuarios_resumo_view) só devolve usuários ativos.
Favoritos: como o ID de uma aplicação é derivado
favorites.js não depende de nenhum atributo data-* dedicado para identificar "o que é favoritável" — ele varre .sidebar a.nav-item, .sidebar a.nav-subitem e deriva um ID estável a partir da própria estrutura/texto do menu (pidCollectFavoritableApps), igual a antes da migração. Esse ID é o que vira app_id em POST /api/favoritos/ e na URL de DELETE /api/favoritos/{app_id}/:
- Se o
<li>do link já temdata-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 +corhex, validada porvalidar_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.pypopulaCATEGORIAS_EVENTO_SEEDcomo ponto de partida, mas a lista é editável dali em diante.CompromissoAgenda.eh_eventomarca 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) edescricao(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 semprevisibilidade="todos"por baixo, não se mistura visualmente com um "Todos" comum (ver parágrafo acima).
Pill do compromisso: sempre "HH:MM Título", nada mais — todo pill mostra só horário+título, nunca o nome do dono, pra manter o mesmo formato/tamanho independente da categoria ("simétrico", pedido explícito do usuário; a primeira versão acrescentava "— Nome do dono" direto no texto dos compromissos que não eram do usuário, o que descalibrava o visual porque nomes têm tamanhos bem diferentes). Todo pill é clicável (cursor:pointer na classe base .calendar-event, não só em --somente-eu): se ev.sou_dono, abre o modal de edição de sempre (#ic-modal, fecha também clicando fora — event.target === modal, mesmo padrão de links-ferramentas.js/ramais-lookup.js); senão, abre um modal novo, só leitura (#ic-view-modal/openViewModal(), mesmo fecha-ao-clicar-fora), com data/horário por extenso, dono_nome (rotulado "Agendado por:", não "Responsável" — mudança de nomenclatura pedida pelo usuário), o rótulo da visibilidade (PID_IC_VISIBILIDADE_LABELS, mapeia ev.visibilidade pro texto exibido nos chips) e, só quando ev.visibilidade === "departamento", o nome do departamento (ev.departamento_compartilhado_nome, campo já vinha do serializer). É esse popup — não o texto do pill — que carrega toda a informação que antes tentava caber na própria pílula.
Célula do dia com altura fixa, lista de compromissos rolável (.calendar-day/.calendar-day__events em calendario.css): .calendar-day tem height fixo (108px desktop, 76px no breakpoint mobile — antes era min-height, o que deixava a linha inteira da grade crescer quando um dia tinha muitos compromissos, desalinhando a altura de todas as células daquela semana). Os pills não são mais filhos diretos de .calendar-day — calendar-individual.js (render()) os agrupa num <div class="calendar-day__events"> (flex:1; min-height:0; overflow-y:auto) irmão de .calendar-day__header. .calendar-day (o item de grid, não só o __events interno) também precisa de min-height:0 + overflow:hidden — sem isso, o "tamanho mínimo automático" que grid/flexbox calculam por padrão pra um item (baseado no conteúdo, ignorando height explícito) ainda fazia a linha da grade crescer pra caber todos os pills, mesmo com a célula e o overflow-y:auto do __events configurados certinho por dentro — o corte real só acontece quando o próprio item de grid para de contribuir com seu min-content pro cálculo da altura da linha (min-height:0/overflow não-visible fazem isso). Resultado: o cabeçalho (número do dia + botão de adicionar) fica sempre fixo, todas as linhas da grade têm a mesma altura sempre, e uma barra de rolagem aparece dentro da célula só quando os compromissos daquele dia não cabem nos 108px/76px disponíveis.
Agenda completa do dia (#ic-day-modal, openDayModal() em calendar-individual.js): clicar no número do dia (.calendar-day__number, cursor:pointer + destaque no hover) abre um popup com todos os compromissos do dia (respeitando os filtros ativos, mesmo eventosDoDia usado pra desenhar a célula) mais o feriado, se houver — sem o corte de altura/rolagem da célula, já que o .day-modal-list (calendario.css) tem max-height:360px próprio, bem maior que os 108px da grade, e os pills ali dentro voltam a ter white-space:normal (podem quebrar linha) em vez do nowrap+ellipsis da grade, então nada aparece cortado. Pra evitar duplicar a criação dos pills em dois lugares (grade e popup), criarPillCompromisso(ev)/criarPillFeriado(iso, nome) foram extraídas como funções reaproveitadas por render() e por openDayModal() — mesmo elemento, mesmo clique (editar/ver detalhes/ver nome do feriado), só muda o container onde entram. Clicar num item dentro do popup fecha o popup da agenda antes de abrir o modal de destino (edição/visualização/feriado), pra não empilhar dois overlays ao mesmo tempo. O botão "Novo Compromisso" do popup pré-preenche a data com o dia clicado (mesmo mecanismo do "+" de cada célula).
Feriados no Calendário Individual (GET /api/feriados/?ano=AAAA, feriados_view em views.py): usa a lib holidays (PyPI, requirements.txt) pra devolver os feriados nacionais + estaduais do Paraná (holidays.Brazil(years=ano, subdiv="PR", language="pt_BR"), categoria public — o default da lib, exclui pontos facultativos tipo Carnaval/Corpus Christi) do ano pedido, como [{"data": "AAAA-MM-DD", "nome": "..."}]. language="pt_BR" é passado explicitamente — sem isso, a lib pode cair pro locale do processo do servidor (que nem sempre é pt_BR, ex.: environment com LANG/LANGUAGE em inglês) em vez do default_language da classe Brazil, fazendo os nomes virem em inglês ("Independence Day" em vez de "Independência do Brasil") mesmo com o resto do portal em português. De propósito não tem feriado municipal de Foz do Iguaçu aqui — nenhuma lib de feriados cobre granularidade de município brasileiro (a holidays só tem um caso especial hardcoded pra "São Paulo Capital", nada além disso), e manter uma lista municipal certa exigiria curadoria manual + atualização por decreto da Prefeitura a cada ano; o usuário decidiu deixar de fora por enquanto, só nacional/estadual mesmo. Se algum dia precisar do municipal, a rota certa é o usuário fornecer a lista oficial (decreto da Prefeitura) pra virar uma tabela fixa no código, não tentar adivinhar/inferir datas.
No frontend (calendar-individual.js), carregarFeriados(ano) busca e cacheia por ano (Map em memória, só refaz a requisição ao trocar de ano); render() busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de viewYear. Cada célula de dia feriado ganha a classe .is-feriado (fundo tingido de vermelho, rgba(var(--danger-rgb), 0.1)) e um pill (.calendar-day__holiday, mesmo visual dos pills de compromisso — .calendar-event, só que com fundo --danger fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de .calendar-day__header, fora de .calendar-day__events — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com text-overflow:ellipsis quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por ;, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver feriados_view); clicar no pill abre #ic-holiday-modal (openHolidayModal(), mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.
Gerente/coordenador vê a agenda individual da equipe: CompromissoAgendaViewSet.get_queryset acrescenta Q(visibilidade="somente_eu", dono__in=usuario.liderados.all()) às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em Usuario.liderados (ver seção "Liderança") também enxerga os compromissos privados ("somente_eu") de cada liderado, mas sem poder editá-los (sou_dono continua False pra esses, CompromissoAgendaViewSet.get_object já barra escrita de quem não é dono). Não há necessidade de checar usuario.lideranca explicitamente na query — liderados só é populado através de fluxos que já exigem esse flag (ver "Liderança"), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.
Lembrete com horário comercial (CompromissoAgenda.lembrete_antecedencia, opcional — "" = sem lembrete; "1h"/"2h"/"4h"/"24h"): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (notif-bell), que já era recalculado a cada carregamento de página (sem processo em segundo plano). CompromissoAgenda.calcular_notificar_em() (models.py) calcula esse horário contando lembrete_antecedencia horas de expediente (seg-sex, 8h-18h, COMPROMISSO_HORARIO_COMERCIAL_INICIO/_FIM) pra trás a partir de data+horario — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (_janela_comercial/_dia_util_anterior, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem horario definido no compromisso (evento de dia inteiro) ou sem lembrete_antecedencia, não há o que calcular e calcular_notificar_em() retorna None. O resultado é exposto só leitura via notificar_em no serializer (ISO datetime ou null); pidBuildEventNotifications() (notifications.js) só inclui um compromisso na lista do sino quando notificar_em não é nulo e já foi atingido (now >= notificar_em) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).
widgets.js mantém um registro extensível PID_WIDGET_TYPES ({ "<chave>": { label, description, href, linkLabel, visibleIf? } }) — hoje existem "calendario-individual" e "links-favoritos" (ver seção própria abaixo). href/linkLabel alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra calendario-individual.html, então ao adicionar um tipo novo sempre preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. visibleIf(me) é opcional — quando presente, filtra o tipo tanto do picker (renderPicker()) quanto da grade já adicionada (renderWidgets()), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: links-favoritos só aparece pra quem tem apps["links-ferramentas-visualizar"] em links-ferramentas). Para adicionar um novo tipo de widget: registrar a entrada em PID_WIDGET_TYPES e adicionar um case/if em widgetBodyFor() que retorne o HTML do corpo do card; o picker (#widget-picker-modal) e a grade (#widgets-grid) já lidam com adicionar/remover genericamente via /api/widgets/.
Reordenar e redimensionar widgets (WidgetUsuario.ordem/largura/altura, por usuário): .widgets-grid é display:flex; flex-wrap:wrap (não mais CSS Grid — precisava permitir que cada .widget-card tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (dragstart/dragover/drop em #widgets-grid, PATCH /api/widgets/{tipo}/ só nos itens cujo ordem mudou) — a diferença é que o draggable="true" fica só em .widget-card__header (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (resize: both em .widget-card, ativo no canto inferior direito). Redimensionar usa esse resize: both do CSS (sem JS de arraste custom) — um ResizeObserver por card (observeWidgetSizes()) detecta a mudança de tamanho e salva largura/altura com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só .widget-card__body tem overflow-y: auto (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).
Links & Ferramentas
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com duas aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (<li data-section="links-ferramentas"><a href="links-ferramentas.html">), agora é um nav-group expansível (mesmo padrão de "Portais"/"Auditorias") com dois nav-subitem: "Links & Ferramentas" (data-app="links-ferramentas-visualizar", mesma URL de antes) e "Acessos Gerais" (data-app="acessos-gerais-visualizar", acessos-gerais.html). Isso teve um efeito colateral em favorites.js: como o <li data-section="links-ferramentas"> não tem mais um <a class="nav-item"> direto (virou um <button data-group-toggle>), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio app_id derivado ("links-ferramentas__links-ferramentas"/"links-ferramentas__acessos-gerais", ver "Favoritos" acima). Um favorito antigo com app_id === "links-ferramentas" (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em "Favoritos": mudar a forma como um item aparece no menu muda o app_id derivado.
LinkFerramenta é uma lista global/compartilhada (sem FK pra Usuario, ao contrário de Favorito/WidgetUsuario/NotificacaoDispensada) — todo usuário com apps["links-ferramentas-visualizar"]=True em permissoes["links-ferramentas"] vê os mesmos cartões via GET /api/links-ferramentas/ (ver "Padrão visualizar/editar" acima).
links-ferramentas.js também gateia o conteúdo da própria página por apps["links-ferramentas-visualizar"] (#lf-no-access/#lf-content em links-ferramentas.html, mesmo padrão do .no-access de portal.html) — isso existe porque o sidebar (data-section="links-ferramentas" em access.js) só esconde o nav-group inteiro com base no enabled do módulo (e cada sub-item individualmente com base no seu -visualizar), então alguém sem apps["links-ferramentas-visualizar"] mas que navegue direto pra URL (ou tenha enabled=true sem essa flag, uma combinação tecnicamente possível já que são independentes) via GET no backend recebia 403 e via a tela renderizada com "Nenhum link cadastrado ainda." em vez de uma mensagem de acesso negado. Ao adicionar uma aplicação nova no padrão visualizar/editar, replicar esse gate (como acessos-gerais.js já faz) — não basta confiar em access.js escondendo o link do menu.
Só quem tem apps["links-ferramentas-editar"]=True (me.permissoes_efetivas["links-ferramentas"].apps["links-ferramentas-editar"], já unido no servidor) vê em links-ferramentas.html os controles de administração: botão "Adicionar Link" no topo e, em cada cartão, setas de mover para cima/baixo + X de remover (links-ferramentas.js, gated no frontend por essa flag, e reforçado no servidor por PermissaoApp("links-ferramentas", "links-ferramentas-editar")/PermissaoApp("links-ferramentas", "links-ferramentas-visualizar") conforme o método HTTP).
- Ordenação: campo
ordem(inteiro, semunique) emLinkFerramenta,Meta.ordering = ["ordem", "id"]. Não existe endpoint de reorder em lote no backend — o cliente é quem decide quaisPATCHes disparar. Duas formas de reordenar na UI, ambas emlinks-ferramentas.js: as setas (swapOrdem()) trocam oordemde dois itens adjacentes com duas chamadasPATCH; arrastar um cartão (drag-and-drop nativo HTML5,.lf-card--draggable/dragstart/dragover/dropno#lf-grid) recalcula a lista inteira em memória e envia umPATCHsó para os itens cujoordem(índice na nova ordem) realmente mudou — como não háuniqueemordem, 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 calculaordem = max(ordem atual) + 1emLinkFerramentaViewSet.perform_create— qualquerordemenviada pelo cliente no POST é ignorada. - Ícone:
iconeé umImageFieldopcional (upload real, não URL) — exige Pillow (requirements.txt) eMEDIA_URL/MEDIA_ROOT(settings.py, servido emDEBUGporconfig/urls.py). Sem ícone, o cartão cai num SVG de fallback (PID_LINK_DEFAULT_ICONemlinks-ferramentas.js, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja comomultipart/form-data(FormData), não JSON — ver a nota sobrepidApiRequestacima. Limite de tamanho: 2MB, checado em dois lugares —validar_tamanho_icone_link(validator do campoiconeemmodels.py, é a checagem que vale de verdade, roda viaLinkFerramentaSerializer.is_valid()) e uma checagem espelhada emlinks-ferramentas.js(PID_LINK_ICON_MAX_BYTES, nochangedo 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 —validatorsno campo entra nodeconstruct()). - 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ó comapps["links-ferramentas-editar"], ao lado das setas de mover) chamaopenModal(link)pré-preenchendo os campos; salvar despachaPATCH /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 (inputtype="file"não aceita valor pré-preenchido por segurança do browser) — não enviar o campoiconeno PATCH mantém o ícone atual; só enviar substitui. - Favoritos por link (
LinkFerramentaFavorito, model dedicado — não confundir comFavorito, que marca aplicações inteiras do menu): estrela em cada cartão (.lf-card__favorite, visível pra qualquer um comapps["links-ferramentas-visualizar"], independente de editar) viaPOST/DELETE /api/links-ferramentas-favoritos/{link_id}/(natural key é oiddo link, igual ao padrãoapp_id/notif_iddeFavorito/NotificacaoDispensada). Só afeta a ordem de exibição dentro da própria tela —links-ferramentas.jsbuscalinkse favoritos em paralelo e reordena em memória (sortFavoritesFirst()) pra mostrar favoritos primeiro, preservando oordemrelativo dentro de cada grupo; oordemcompartilhado doLinkFerramentanunca é 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 comapps["links-ferramentas-editar"]também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novoordemcompartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho. - Widget "Links Favoritos" (
links-favoritosemPID_WIDGET_TYPES,static/js/widgets.js): lista emportal.htmlsó os links favoritados, cada linha com ícone pequeno (.widget-links-list__icon, fallbackPID_WIDGET_LINK_DEFAULT_ICON— cópia local do glifo dePID_LINK_DEFAULT_ICON, já quewidgets.cssnão carregalinks-ferramentas.css) + nome, a linha inteira é um<a target="_blank">pro mesmo destino do cartão original. Depende depidFetchLinks/pidFetchLinkFavoritos, entãolinks-ferramentas.jsfoi incluído emportal.htmlsó por causa dessas funções de dados — seu handler deDOMContentLoadedretorna cedo lá (if (!grid) return, não existe#lf-gridemportal.html), mesmo padrão de guarda deprofiles.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_restritosM2M praPerfilAcesso, ver abaixo) — só a categoria (ex.: "Banco de Dados/API"). Lista compartilhada, sem FK praUsuario.AcessoGeral(secaoFK,nome,url,usuario,senha,observacoes,ordem) — a linha em si.senhaé umCharFieldem 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).observacoesguarda HTML sanitizado (ver "Observações ricas" abaixo), com umvalidators=[validar_tamanho_observacoes_acesso](models.py) limitando a 2.000.000 caracteres — generoso o bastante pra várias imagens embutidas, mas evita umTextFieldsem 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 entreperfis_restritose os perfis do usuário logado;AcessoGeralViewSet.get_queryset()aplica o mesmo filtro viasecao__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 camposecao(oPrimaryKeyRelatedFieldque valida o POST/PATCH) à mesma queryset filtrada — sem isso, alguém comacessos-gerais-editarmas 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 temacessos-gerais-editarmas renderizado viainnerHTMLpra qualquer um comacessos-gerais-visualizar, ele passa por um allowlist estrito no backend antes de salvar —AcessoGeralSerializer.validate_observacoes()rodanh3.clean()permitindo apenas tags de texto básicas +<img>(ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS/_ALLOWED_ATTRS/_ALLOWED_SCHEMESno topo deserializers.py) — sem<a>/<script>/atributos de evento (onerroretc. são descartados por não estarem na allowlist de atributos).url_schemesinclui"data"de propósito, já que as imagens embutidas sãodata: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 dobleach(usado numa primeira versão desta funcionalidade) porque obleachestá oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026;nh3tem 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.observacoesrepopula o editor com o HTML já sanitizado (imagens inclusas); salvar lêformObservacoes.innerHTML(funçãoobservacoesValue(), 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:
- Todo
Usuarioativo — a linha é montada direto do cadastro (nome,Usuario.departamentosjuntados por vírgula,Usuario.ramal); se o colaborador ainda não tem ramal preenchido,numero_exibicaovem vazio e o frontend mostra um badge "Pendente" em vez de reclamar de campo faltando. - As linhas avulsas de
Ramal(semUsuariopor 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 emUsuario.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/numerolivres — sónomeé obrigatório, o resto pode ficar pendente igual a um colaborador de verdade). Editar/excluir uma linha avulsa usaPATCH/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/nomede 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 agerencia_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 queRamalViewSet.list()monta a resposta. Clicar no badge "(AUSENTE)" de uma linha (data-ram-ver-ausencia, qualquer um comapps.visualizarpode abrir) fazGET /api/ramais-ausencias/{id}/e abre o modal "Visualizar Ausência" — campos desabilitados (<input type="date"/"time">mostra a data/hora formatada mesmodisabled, sem precisar formatar manualmente), com "Editar Ausência"/"Deletar Ausência" visíveis só pra quem temapps.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 viaencerrada_manualmente; substituído por editar/excluir de verdade, mais simples e é o que foi pedido). O campoencerrada_manualmentecontinua 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) comtimezone.localdate(), feita no mesmolist()— 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/--goldcrus 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--aniversariotd, aplicado via classe no<tr>emramais.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). Osrcdo iframe só é setado na abertura do modal e volta praabout:blankao 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()emlist()), semordem/drag-and-drop. - Usuário inativo (
is_active=False) não aparece mais no diretório (olist()filtraUsuario.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) sepermissoes_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 porDepartamentocadastrado, buscados deGET /api/departamentos-resumo/na primeira abertura (endpoint dedicado,IsAuthenticated+ checagem manual depermissao_app("ramais", "ramais-visualizar")— não reaproveita/api/departamentos/, que exigegerencia_permissoese 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óstrim— 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/100por 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 (HttpResponsebinário,Content-Disposition: inline), nada é salvo no banco. Diferente do padrão "com histórico" deImportacaoPlanoSaude/IndicadorApuracao. - Tabelas de INSS/IRRF editáveis pelo banco:
ParametroFiscalCustoContratacao(models.py) é um singleton (atual(), semprepk=1, criado sob demanda viaget_or_create) comfaixas_inss/faixas_irrfemJSONField(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.pycontinua existindo só como seed/default da primeira criação da linha (_faixas_inss_padrao/_faixas_irrf_padraoemmodels.py) —calculo.pynunca lêtabelas.pydireto, sempre recebe umParametrosFiscais(dataclass pura, sem ORM) montado porParametroFiscalCustoContratacao.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 comlogo-branco.png+ "De Paula Contadores", nas cores reais da marca (dourado#D3AF4D, marrom#4A3C28, amostradas do própriologo.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 viavigente_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,statusrevisao/concluida, as 2 planilhas anexadas,avisosde processamento),IndicadorApuracaoColaborador(um colaborador dentro de uma apuração, compct_individual/pct_grupo/pct_departamentoe respectivos flags*_ajustado_manualmente, maisdepartamento— FK praIndicadorDepartamento, resolvida na criação via o gerente do colaborador, ver "Departamento organizacional" abaixo),IndicadorApuracaoEmpresa(uma empresa/honorário do colaborador naquele mês) eIndicadorApuracaoResposta(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 emindicadores/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 contraIndicadorCriterio.limiar_percentualpra 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 deixarpapel_aplicavelem branco nesses 3 critérios (um mesmo critério, ex. "balancete", se aplica a 3 tipos ao mesmo tempo, epapel_aplicavelsó 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 emcalculo.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_individualnã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_departamentocontinuam 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 mesmogerentedentro da apuração, epct_departamentoé compartilhado por todos os colaboradores do mesmoIndicadorDepartamento(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 (IndicadorApuracaoColaboradorViewSetsó cobrepct_individual);IndicadorApuracaoViewSet.ajustar_grupo/recalcular_grupo/ajustar_departamento/recalcular_departamentoaplicam a mudança de uma vez a todo o grupo/departamento, mantendo os colaboradores em sincronia (ajustar_departamento/recalcular_departamentorecebemdepartamento— o id doIndicadorDepartamento— 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 porIndicadorDepartamento+ 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áIndicadorDepartamentopra 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 >= 50decide qual aparece selecionada ao carregar), não um campo de texto livre; o valor enviado aajustar-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 decolaborador.gerenteque ainda não estão em nenhumIndicadorDepartamentoGerente; 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()montamapa_gerentes(departamentos.carrega_mapa_gerentes(),{nome_gerente: departamento_id}) uma vez e passa propipeline.processa_apuracao(), que resolvedepartamento_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 pordepartamento_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 degerente/setorantes 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 comdepartamento=Nonee vira um aviso no processamento ("Gerente X não está associado a nenhum departamento..."), além de não ganhar nenhumaIndicadorApuracaoResposta(sem departamento, não há de onde vir nenhum critério).IndicadorApuracaoColaboradorSerializerexpõedepartamento(id) +departamento_nome(com fallbackNone, mesmo padrão decriado_por_nome).Migração em 3 passos (mesmo padrão de qualquer FK obrigatória adicionada a uma tabela já populada):
0033cria os 2 models novos + adicionadepartamentonullable emIndicadorCriterio/IndicadorPercentualTipo/IndicadorApuracaoColaborador(e removesetor/IndicadorSetorApelido);0034(RunPython) cria o departamento "Fisco/Contábil" e aponta todoIndicadorCriterio/IndicadorPercentualTipojá existente pra ele (é literalmente o que a regra única representava até então);0035tornadepartamentoobrigatório emIndicadorCriterio/IndicadorPercentualTipo(não emIndicadorApuracaoColaborador, 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 comdepartamentoem 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 mesmogerente, 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 antigoIndicadorSetorApelidocobria 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 campocomposicao_individualdeIndicadorApuracaoColaboradorSerializer): 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 querecalcula_colaboradorcalcula mas não persiste, por não precisar deles depois de gravarpct_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()usacolaborador.respostas.all()(não.select_related("criterio")) de propósito, pra reaproveitar oprefetch_related("colaboradores__respostas__criterio")queIndicadorApuracaoViewSet.get_queryset()aplica só na actionretrieve— 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_departamentodireto). Existia um segundo<select>Sim/Não ao lado do texto de cada critério (bulk, viaaplicar-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 porIndicadorApuracaoEmpresa, ex.: "Valéria Bonete — Fiscal"), um<select>com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chamaPOST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/(IndicadorApuracaoEmpresaViewSet.trocar_responsavel, serializerIndicadorApuracaoEmpresaTrocarResponsavelSerializercom{colaborador_id}), que só troca a FKcolaboradorda 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ção0031): 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, serializerIndicadorApuracaoColaboradorValidadoSerializercom{validado}) só grava o campo, sem chamarrecalcula_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_atingidaemcalculo.py), mesmo que o RH marque SIM por cima; só critério manual (sempercentual_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 detransaction.atomic()— uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados emMEDIA_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 recebecolaborador_ids(lista, opcional) e só marca a apuração comoconcluidaquando o conjunto pedido bate com todos os colaboradores da apuração (semcolaborador_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ãocolaborador.pct_individualpuro — 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. Quandopct_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" eraParagraph, o resto strings soltas) virouParagraphcom 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 comALIGNà direita/centro um valor mais largo que a coluna (ex.: "Contador (com conciliador)" em Tipo, ou os totais em negrito, mais largos que a mesma string em peso normal) vazava visualmente por cima da célula vizinha em vez de quebrar linha — bug real visto com dados reais (coluna Tipo cobria "Hon. Ajustado"). Coluna "Tipo" ganhou um dicionário de labels curtos só pro PDF (TIPO_LABEL_CURTO: "Contador SC"/"Contador CC" em vez de "Contador (sem/com conciliador)" — mesma abreviação já usada informalmente neste documento) porque o label completo não cabia nem quebrando linha numa coluna estreita; a linha de total virou "Total do Indicador" (era "Total Resultado"). Larguras de coluna e padding lateral (LEFTPADDING/RIGHTPADDING, reduzidos de 6pt padrão do reportlab pra 3pt) ajustados pra caber os maiores valores reais vistos na apuração (ex.: R$ 28.023,16) numa linha só._moeda()usa (não espaço comum) entre "R$" e o número — com espaço comum, quando o valor não cabia numa linha só, o reportlab quebrava exatamente ali, deixando "R$" sozinho numa linha acima do número; com espaço não separável, o "R$" fica sempre grudado à esquerda do número (mesmo que precise de mais espaço na coluna pra caber tudo numa linha, resolvido junto pelas larguras/padding acima). Rótulo da linha de detalhe é "Percentual individual ajustado pela direção" (minúsculo, só a primeira letra maiúscula — não "Percentual Individual Ajustado Pela Direção"). -
aplicar_em_lote(IndicadorApuracaoRespostaViewSet,POST /api/indicadores-apuracoes-respostas/aplicar-em-lote/) aplica o mesmo valor a várias respostas de critério de uma vez — a "múltipla seleção" pedida pelo usuário na tela de revisão. -
"Empresas sem Honorário" (
#ind-empresas-sem-honorario-btn, cor de atenção —--danger, mesma linguagem visual do input/selo de honorário não encontrado, só enquanto houver alguma empresa pendente — sem nada pra resolver, o botão perde a classe.ind-empresas-sem-honorario-btn(volta a.btn-outlineneutro) 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 porcodigo_empresatodas asIndicadorApuracaoEmpresacomhonorario_nao_encontrado=Trueda 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 chamaPOST /api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/(IndicadorApuracaoViewSet.ajustar_honorario_empresa, serializerIndicadorApuracaoAjusteHonorarioEmpresaSerializercom{codigo_empresa, honorario}), que atualiza todas as linhas daquele código nesta apuração de uma vez (recalculando cada colaborador afetado) — diferente dePATCH /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) marcamhonorario_ajustado_manualmente=Truena(s) linha(s) afetada(s) — fica permanente (sem UI de reverter, diferente depct_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 quehonorario_nao_encontradojá foi resolvido (os dois nunca aparecem juntos). Essa tabela também passou a listar porcodigo_empresa(IndicadorApuracaoEmpresa.Meta.ordering, migração0029), não mais por nome. Comocodigo_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ção0030) 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 porcodigo_empresa, as empresas comhonorario_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 endpointajustar-honorario-empresa— o filtro do backend cobreQ(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) emindicador-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 (mesmomax-height:85vhdo popup). Em cada item, o código aparece antes do nome da empresa no cabeçalho (.ind-esh-codigoseguido 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 — ajustapct_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, aplicapct_individual=100a todos, viaPATCH /api/indicadores-apuracoes-colaboradores/{id}/) ou "Reverter" (chama a actionrecalcularde cada um, voltando ao cálculo automático) — sem campo de percentual livre. Os dois botões sãobtn-solid(mesma cor) — só "Cancelar" ficabtn-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_desdefixado 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 demax_digits=7(não 6) — qualquerDecimalFieldque representa um percentual "de 0 a 100" precisa demax_digits >= decimal_places + 3pra caber o "100" exato semDataError: 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 deplanos_saude.regras_empresa.REGRAS_EMPRESAquando "mensalidade" foi custeada por uma regra especial em vez docusteio_por_tipo["mensalidade"]normal, ver "Regra empresa" abaixo), os dois arquivos anexados (planilha_padrao/arquivo_operadora,FileFieldcom o mesmo padrão de validator de tamanho deLinkFerramenta.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 (espelhaLinhaSistemacampo 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_empresaficam comoCharFieldno mesmo formato string do pipeline ("51,69"/"0"), nãoDecimalField, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento.ImportacaoPlanoSaudeAuditoria: espelhaItemAuditoria— os campos extraídos do arquivo da operadora (motivo/nome/valor/detalhe...) são read-only na tela;resolvida/linha_vinculadasã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_INVALIDOsã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 deLinkFerramenta/AcessoGeral— o próprio model não tem FK pra nada; éImportacaoPlanoSaude.regra_custeio_salvaque 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,ImportacaoPlanoSaudeCreateSerializervalida a entrada) salva o model (isso já grava os 2 arquivos emMEDIA_ROOT) e rodapipeline.processa_importacao()de forma síncrona usandoinstance.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/(@actionsem detail) devolvepipeline.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.labeljá 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 empipeline.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) usandoleiaute_sistema.CABECALHO; 1 tipo de lançamento vira um.csvdireto, 2 tipos (mensalidade + coparticipação) viram um.zipcom um.csvpor tipo (zipfileem 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 emImportacaoPlanoSaudeAuditoriaViewSet.resolver(views.py): o item precisa ter um motivo emMOTIVOS_RESOLVIVEISe ainda não estarresolvida(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 mesmotipo_lancamentodo item; (b) ser do mesmo "lado" — titular pra itemtipo="T", dependente pratipo!="T"(D/A) — comparandolinha.nome_dependente/cpf_dependentevazios 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
valordo item de auditoria é dividido emvalor_empresa/valorpela mesma regra de custeio já salva emImportacaoPlanoSaude.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_valoresdo fluxo automático (não existe uma segunda fórmula "manual"). Exceção: quando a importação temregra_empresaconfigurada (ver "Regra empresa" abaixo) e o item é detipo_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_funcigual) — recuperando o valor bruto de cada uma comovalor_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 emtotal_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ó quandoPID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)e!item.resolvida. O modal#ips-vincular-modallista candidatos sem nenhuma chamada de API nova — filtra em memória a partir deimportacaoAtual.linhas(já carregado na revisão) portipo_lancamentoigual, "lado" (titular/dependente) igual e ainda em branco (candidatosVincular()), com uma caixa de busca por nome (renderVincularLista(), mesmo componente.checklist-box/.checklist-searchde outras telas, aqui com<input type="radio">— seleção única, não múltipla). Confirmar chamapidResolverAuditoriaPlanoSaude()e refazpidFetchImportacaoPlanoSaudepra recarregarimportacaoAtual(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ção0038): um registro por operação, nunca apagado (mesmo espírito deresolvidaemImportacaoPlanoSaudeAuditoria— histórico completo).tipo(edicao/inclusao/exclusao),linha(FKSET_NULL— ficanullquando 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 emedicao),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_updatecomparaserializer.validated_datacontraserializer.instance(os valores antes do.save()) e grava umImportacaoPlanoSaudeAlteracaopor campo que de fato mudou (o fluxo atual do frontend já só envia um campo por PATCH, porchangede 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 odados_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 selinhaainda existir (não excluída depois); gravavalor_anteriorde volta no campo.inclusao: só possível selinhaainda existir; deleta a linha diretamente (bypassaImportacaoPlanoSaudeLinhaViewSet.perform_destroy, então não cria um registroexclusaopra essa reversão).exclusao: sempre possível (a linha já está excluída por definição) — recria umaImportacaoPlanoSaudeLinhanova a partir do snapshot emdados_linha(+tipo_lancamentoguardado à parte) e apontaalteracao.linhapra 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" (comwindow.confirm, mesmo padrão de "Remover linha") ou o selo "Revertida" quando já foi desfeita; confirmar chamapidReverterAlteracaoPlanoSaude()e refazpidFetchImportacaoPlanoSaude()(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?}) — sempre200 {"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 dearquivo/tipoinválido/operadoraausente quandotipo="operadora"vira 400 de verdade)._valida_planilha_padrao()rodaleiaute_sistema.le_planilha_padrao();_valida_arquivo_operadora()rodaOPERADORAS[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 nofinally; 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\tem vez de;) viravalido=Falsecom uma mensagem específica pra aquele arquivo; 0 linhas/indivíduos extraídos (arquivo no formato certo mas vazio) também viravalido=False. - Frontend (
importacao-plano-saude.js):criarValidadorArquivo()é a fábrica reaproveitada pelos dois campos (validadorPlanilha/validadorArquivo) — nochangedo<input type="file">, chamapidValidarArquivoPlanoSaude()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 (formOperadorachange) 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á voltouvalido=False— mas isso é só uma segunda barreira de UX; ocreate()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 — akeydeplanos_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 deImportacaoPlanoSaude, ver acima) eobservacoes(texto livre). - Validação reaproveitada, não duplicada:
RegraCusteioPlanoSaudeSerializer.validate()eImportacaoPlanoSaudeCreateSerializer.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:ImportacaoPlanoSaudeCreateSerializerrecebe campos multipart achatados (custeio_mensalidade_titular,limite_valor_mensalidade_titular...),RegraCusteioPlanoSaudeSerializerrecebe ocusteio_por_tipojá aninhado como JSON puro. limite_valor/percentualsempre 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 peloGET, já vem com esses valores comofloat(formato final persistido). PraRegraCusteioPlanoSaudeSerializeraceitar os dois formatos sem corromper o valor ("150.0"seria lido errado como 15000 porparse_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) chamalimparRegraSelecionada()(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) elimparOperadoraSelecionada()(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 deresetForm()justamente pra serem reaproveitadas aqui, e as três juntas são exatamente o queresetForm()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-fieldemimportacao-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 mesmamensagemErroCusteio()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á quelabelagora vem prefixado com o código de cadastro no Questor (verGET /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 eventochangedo<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 porresetForm()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, praRegraCusteioPlanoSaude) registra qual regra (se alguma) estava aplicada no formulário no momento do "Processar" — só informativo, não influenciacusteio_por_tipo(que já é o que de fato vale pro processamento) nem o processamento em si. Preenchido no submit comregraAplicadaId(JS) quando "Regra empresa" não está marcada (as duas rastreiam coisas diferentes e nunca são enviadas juntas — marcar "Regra empresa" já limparegraAplicadaIdvialimparRegraSelecionada(), e aplicar uma regra de custeio salva já limpa "Regra empresa" vialimparRegraEmpresa()dentro deaplicarCusteio()). É o que alimentaregra_custeio_salva_nome/regra_custeio_salva_observacoesna 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 dechangedos dois checkboxes +aplicarCusteio()/limparRegraEmpresa()) quanto implicitamente no backend (ImportacaoPlanoSaudeCreateSerializer.validate()gravacusteio_por_tipo["mensalidade"] = {}quandoregra_empresavem preenchido, ignorandocusteio_mensalidade_titular/dependente). Otipo_lancamentopersistido naImportacaoPlanoSaudeLinhacontinua 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 temlabel(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) eobservacoes(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) resolveREGRAS_EMPRESA[obj.regra_empresa]["observacoes"]a cada carregamento da importação — não é um campo persistido naImportacaoPlanoSaude, 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 (hiddenpor padrão) quando o campo vem preenchido — a maioria das regras pode não terobservacoescadastrada. Mesmo bloco reaproveitado pra "Regra de custeio salva" (verImportacaoPlanoSaude.regra_custeio_salvae "Regras de custeio salvas" abaixo): se não háregra_empresa_observacoesmas a importação temregra_custeio_salva_observacoes(a observação real daRegraCusteioPlanoSaudeaplicada, viaCharField(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, veraplicarCusteio()/handler deformTipoRegraEmpresaacima). - 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 recebendovalor_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 comvalor_empresa=559,57/valor=0(coberto integralmente), sobra661,61-559,57=102,04de teto pro titular, que sai comvalor_empresa=102,04/valor=212,08. Se os dependentes sozinhos já consumirem o teto inteiro, o titular fica comvalor_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 quematcher._casa_por_nomejá faz (pornumero_titular);_casa_por_cpfnã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 emviews.pypra 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 ocodigo_empresaesperado 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 deLinhaSistema.eh_linha_titular()): roda tanto contraLinhaSistema(pipeline, na criação da importação) quanto contraImportacaoPlanoSaudeLinha(model Django, no recálculo pós "Vincular pessoa" — verviews._recalcula_familia_regra_empresae "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étodoeh_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-rowsem "Excluir"/"Editar" — é um registro fixo) buscado deGET /operadoras/regras-empresa/uma vez por abertura do formulário (regrasEmpresaCache).regraEmpresaSelecionada(JS,{key, label}) é lido no submit (formData.append("regra_empresa", ...)) e emmensagemErroCusteio()(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) e .pid-icon-eye/pidIconBlink (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por todas as páginas sem exceção (inclusive index.html). |
layout.css |
Casca do shell: .app-shell, .sidebar*, .nav-*, .fav-toggle, .topbar*. A sidebar usa tokens congelados, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham :root[data-theme="light"]. Exceção deliberada: --sidebar-text-primary/--sidebar-text-secondary/--sidebar-text-muted (texto/ícone do menu) são sobrescritas em :root[data-theme="light"] (tokens.css) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também .page-content (largura do conteúdo de cada página, max-width:1200px centralizado por padrão) + o modificador .page-content--wide (max-width:1600px) — portal.html ("Principal") é a única página que usa só .page-content puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (perfis-acesso.html, usuarios.html, links-ferramentas.html, acessos-gerais.html, ramais.html, calendario-individual.html, importacao-plano-saude.html) usam class="page-content page-content--wide" no <main>, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com page-content--wide. |
components.css |
UI genérica reutilizável: .btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline, .modal-overlay/.modal-card (moldura genérica de modal, + o modificador .modal-card--wide pra quando precisa de mais espaço horizontal) e também .modal-field/.modal-field-row/.modal-checkbox/.modal-error/.modal-actions (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo select/textarea dentro de .modal-field, com seta customizada via background-image porque o nativo do browser destoa do tema escuro), .app-card*, .no-access, .checklist-box/.checklist-item/.checklist-item__info/.checklist-item__nome/.checklist-item__departamento/.checklist-empty/.checklist-search/.checklist-select-all (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em usuarios.html) e .dual-select/.dual-select__* (vinculação em duas tabelas — não vinculados/vinculados, ver seção "Liderança" — usada em usuarios.html e no modal "Gerenciar Usuário" de todo shell). |
perfis-acesso.css |
.pa-* (tela de Perfis de Acesso), incluindo as seções (.ua-section*) e campos específicos (.ua-inline-add/.ua-departamento-item/.ua-active-toggle/.ua-liderados-field) do formulário de edição de usuarios.html. |
calendario.css |
Só .calendar-* (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas .modal-field/.modal-checkbox/.modal-error/.modal-actions de components.css. |
widgets.css |
.widgets-*, .widget-card*, .widget-picker-* — só usado em portal.html. O topbar da tela inicial não tem mais título/slogan nenhum (<h1 id="portal-title"> — chegou a existir brevemente com o slogan "Grandes aplicações de todos os tamanhos" em fonte "Pinyon Script"/dourado, removido a pedido do usuário na mesma rodada; ver login.css abaixo pra onde o slogan acabou indo) — o <link> do Google Fonts em portal.html também foi removido junto, já que não sobrou nenhum uso de fonte customizada nessa página. |
login.css |
Só usado em index.html; .login-card__title ("Portal Interno da De Paula") usa a fonte "Bree Serif" (importada só nesta página). O slogan da marca ("Grandes aplicações de todos os tamanhos.", pid-marca-leiame.md tem esse e um segundo, "Ainda funciona. Agora pensa.", não usado em lugar nenhum) mora aqui, não no topbar de portal.html (onde chegou a existir e foi removido, ver widgets.css acima) — passou primeiro pelo rodapé do card (abaixo de "Esqueceu sua senha?...") antes de subir pra logo abaixo do título, posição atual (pedido explícito do usuário). .login-heading (display:flex; flex-direction:column; gap:var(--space-1)) agrupa .login-card__title e .login-slogan, com um espaçamento bem menor entre os dois (--space-1) do que o gap:var(--space-5) que .login-card usa entre seus próprios filhos diretos — sem esse agrupamento, o slogan ficaria longe demais do título pra parecer uma assinatura. .login-slogan usa a fonte script "Pinyon Script" (mesmo <link> do Google Fonts de .login-card__title, agora com as duas famílias), font-size:1.2rem (menor que o 1.3rem do título do card, pedido explícito do usuário) e cor dourada fixa #c6a24a (cor da marca, não um token de tema/--accent). O card do login é congelado escuro nos dois temas (--login-card-bg/--login-field-bg/--login-border/--login-text-*, definidos só em :root de tokens.css, nunca redefinidos em :root[data-theme="light"] — mesmo padrão de --sidebar-*, ver comentário ao lado deles em tokens.css) — pedido explícito do usuário; a logo dentro dele também não alterna mais por tema (ver "Ícone do login e da sidebar são clicáveis" acima — decisão de uma rodada seguinte, revertendo a alternância que existia antes). Só o fundo da própria página ao redor do card (.login-page) continua acompanhando o tema claro/escuro, mas de formas diferentes em cada um: no escuro (padrão), background: linear-gradient(var(--overlay-scrim), var(--overlay-scrim)), radial-gradient(circle at 20% 20%, rgba(var(--accent-rgb), 0.25), transparent 45%), var(--bg-canvas) — um scrim escuro + um brilho radial na cor do tema sobre o --bg-canvas escuro, pensados pra dar profundidade; no claro, :root[data-theme="light"] .login-page zera isso pra só background: var(--bg-canvas) (pedido explícito do usuário, "mesmo tom do fundo da tela principal") — o scrim escuro por cima de um --bg-canvas já claro resultava num cinza/marrom acinzentado que não batia com o fundo de verdade de portal.html (que é só var(--bg-canvas) puro, via body em base.css); o brilho radial (rgba(accent, 0.25)) também sai junto, já que sem o scrim por baixo ele ficaria evidente demais sozinho contra um fundo claro. |
links-ferramentas.css |
.lf-* — só usado em links-ferramentas.html. |
acessos-gerais.css |
.ag-* — só usado em acessos-gerais.html. |
ramais.css |
.ram-* — só usado em ramais.html; a tabela em si reaproveita .pa-table*/.pa-row-actions de perfis-acesso.css (mesmo padrão que usuarios.html já usa sem CSS próprio). |
ramais-lookup.css |
.ram-lookup-* — modal de consulta rápida de ramais (ver "Ramais" abaixo), usado em portal.html/links-ferramentas.html/calendario-individual.html. Tabela autocontida (não reaproveita .pa-table porque essas 3 páginas não carregam perfis-acesso.css). |
importacao-plano-saude.css |
.ips-* — só usado em importacao-plano-saude.html; carrega perfis-acesso.css também, pra reaproveitar .pa-table/.pa-tabs/.pa-table-wrap na tabela editável da revisão e nas abas. |
custo-contratacao.css |
.cc-* — só usado em custo-contratacao.html. |
indicador-desempenho.css |
.ind-* — só usado em indicador-desempenho.html; carrega perfis-acesso.css também, pelo mesmo motivo de importacao-plano-saude.css (tabela/abas de revisão). |
Ao adicionar uma tela nova que precise de modal, reuse .modal-overlay/.modal-card de components.css e só crie estilos de campo próprios se o formulário não for um caso simples de texto/select (que já tem equivalente em .pa-field ou .modal-field).
Animações
Três @keyframes genéricos em base.css (pidFadeIn, pidFadeSlideUp, pidScaleIn) — reutilizados via animation (não transition) em elementos que entram/saem do layout via hidden/display:none (modal, dropdown, .page-content a cada navegação, .login-card), porque só animation reinicia sozinho quando um elemento passa de display:none para visível; transition não anima essa troca (não há frame intermediário). Duração sempre curta (120–200ms) — pedido explícito do usuário: "fluidas, porém rápidas, otimizando o tempo". Botões (.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline/.icon-btn) ganharam transform: scale() no :active como feedback de clique.
Nenhuma dessas animações respeita prefers-reduced-motion — decisão deliberada do usuário ("as animações devem ignorar a preferência de não mostrar animações ou de acessibilidade do computador do usuário"), não um descuido. Não adicionar um bloco @media (prefers-reduced-motion: reduce) desativando isso sem confirmar de novo com o usuário, já que contraria um pedido explícito.
Animação de intro do login
Ao apertar "Entrar" com sucesso, index.html toca uma animação de marca em tela cheia (~6,2s: 500ms de fade + ~5,7s de animação) antes de navegar pra portal.html — pedido explícito do usuário, inspirado num arquivo pid-intro-escuro.html que ele forneceu (mesma pasta de origem dos SVGs da marca "P.I.D.", C:\Users\Depaula\Documents\Projetos\LOGOS\pid-marca).
Origem do arquivo — por que não foi só copiado: pid-intro-escuro.html não é HTML/CSS simples, é um bundle auto-contido de um editor de "Design Canvas" (Anthropic) — um <script type="__bundler/manifest"> com um JSON mapeando uuid → recurso ({mime, compressed, data}, data sendo base64 de um gzip), incluindo o JSX fonte de verdade (pid-intro.jsx, a composição em si) e a biblioteca de motion (animations-v3.jsx, easing/interpolação), montados em tempo real por um runtime React + motor de composição por "tempo autorado" (useComposition/CompositionStage) que não faz sentido carregar em produção (pesado, e pensado pra edição/export de vídeo, não pra rodar dentro de uma página de login). A coreografia foi extraída (decodificado gzip+base64 por uuid, script Python ad-hoc) e portada fielmente pra vanilla JS/CSS — mesmas durações de cena, mesmas curvas de easing (hand-rolled, estilo Popmotion) e mesmo timing de piscada dos olhos do original; só o destino final do "voo" do ícone foi recalibrado (ver "Cena Portal" abaixo).
As 4 cenas (PID_INTRO_CUES/PID_INTRO_TOTAL em login-intro.js — Build:0, Face:1, Wordmark:2, Portal:4.8, total 5.7): durações aceleradas em relação ao arquivo original (Build/Face de 2,6s/2s pra 1s cada, Portal de 2,3s pra ~0,9s — pedido explícito do usuário, "acelere um pouco a velocidade na qual a logo é construída"), exceto Wordmark (2,8s, igual ao original) — "a parte onde aparece o nome do portal deve permanecer a mesma", pedido explícito também. Os deslocamentos internos de cada cena (quando cada elemento começa/termina de animar) foram reproporcionados pra caber na duração nova, não são mais os valores originais do arquivo — só a cena Wordmark manteve os originais, já que a duração dela não mudou.
- Build (1s): o contorno do corpo do ícone se desenha (
stroke-dashoffset,pathLength="100"normaliza o path pra unidades 0–100 independente da geometria real), preenche a cor, a aba dourada "cai" (easeOutBack, dá um leve overshoot) e a "tela"/rosto do ícone abre (scale+opacity). - Face (1s): os dois olhos aparecem com "pop" (
easeOutBack) e piscam uma vez (pidIntroBlinkAt()— uma janela de 220ms em que a escala vertical do olho vai de 1 a 0 e volta a 1, formando o fecha-e-abre; a mesma função é reaproveitada em 2 momentos diferentes agora, ver abaixo — o terceiro blink do original, na cena Portal, foi removido: a cena ficou curta demais pra caber um blink visível com o ícone já encolhendo/voando), o sorriso dourado se desenha (mesma técnica destroke-dashoffsetdo corpo). - Wordmark (2,8s, inalterada): o ícone desliza pra esquerda (
SHIFT_X=-100px) enquanto "P.I.D." (fonte "Space Grotesk" 700, cada letra com um "." dourado à parte) + uma régua dourada (scaleX) + a tagline "Portal Interno da De Paula" (fonte "DM Mono", uppercase, letter-spacing largo) aparecem — cada letra com seu próprio atraso escalonado (CUES.Wordmark + 0.25 + i*0.13). - Portal (~0,9s): a wordmark esmaece quase na hora (
PortalaPortal+0.3), o ícone encolhe (de 160px pra 40px — o mesmo tamanho de.sidebar__logo--icon, ver "Logos emstatic/img/" acima) e "voa" até o canto superior esquerdo em 0,5s (Portal+0.05aPortal+0.55), e o stage inteiro (ícone+wordmark) esmaece logo depois, sobrepondo o fim do voo (Portal+0.5aPortal+0.9) — sem pausa parada entre o ícone assentar e o fade começar (ajuste de uma rodada anterior, que já tinha comprimido essa cena de 2,3s pra ~0,95s; esta rodada só encurtou mais um pouco, até ~0,9s). Diferença deliberada em relação ao original (além do tempo): lá o destino é um pixel fixo dentro de um frame de vídeo de exportação 1920×1080 (logoX:-892, logoY:-496, coordenadas que não existem em página nenhuma); aqui,pidIntroCornerTarget()calcula o alvo em tempo real a partir dewindow.innerWidth/innerHeight, mirando um ponto perto do canto real da janela — a ideia de "o ícone termina indo pro cabeçalho/sidebar do app" só faz sentido revisitada assim, já que o original nunca foi pensado pra rodar dentro de uma página de verdade. - As duas piscadas do olho (
Face+0.8,Wordmark+1.1) e as três funções de easing usadas (easeOutCubic/easeInOutQuad/easeOutBack) seguem as fórmulas exatas extraídas deanimations-v3.jsx— ver o código delogin-intro.jsse precisar ajustar timing, não redesenhar do zero.
Fade de entrada, antes do ícone começar a se desenhar (pedido explícito do usuário — a primeira versão fazia o overlay aparecer de repente por cima do card ainda visível, "de um modo bruto"; a duração foi ajustada de 350ms pra 500ms numa rodada seguinte, "está muito rápido... só pra ficar mais fluído"): pidPlayLoginIntro() aplica .is-leaving no .login-card (login.css, opacity:0; transform:scale(0.98), transição de 500ms) e .is-visible no #login-intro (opacity:0→1, mesmos 500ms, PID_INTRO_FADE_MS) ao mesmo tempo — o card se dissolve enquanto o overlay (já na cor final sólida) sobe por cima, um cross-fade real, não uma troca instantânea. Só depois desses 500ms (setTimeout) é que o loop de requestAnimationFrame do ícone começa (start = performance.now() é atribuído só nesse momento, não antes).
Arquivos: static/css/login-intro.css (só index.html) — formas idênticas às já usadas em pid-icone-escuro.svg/pid-logo-horizontal-escuro.svg (hex hardcoded — #7b5ba8/#c6a24a/#f2ede3, não são tokens de tema, são a paleta fixa da marca, mesmo espírito de .login-slogan já hardcodar #c6a24a); a exceção é o fundo do overlay, que reage ao tema (ver "Tema claro" logo abaixo) em vez de ser um hex fixo da marca. static/js/login-intro.js expõe pidPlayLoginIntro(onDone) (global, chamada só por auth.js) — monta o loop de requestAnimationFrame, calcula cada valor (bodyDraw, eyeL, shift, fly, ...) a partir de T (segundos decorridos desde o início do loop, via performance.now()) e escreve direto nos atributos/estilo dos elementos do overlay (#login-intro, já presente e hidden no HTML de index.html); chama onDone() quando T atinge PID_INTRO_TOTAL. Fontes "Space Grotesk" (peso 700) e "DM Mono" adicionadas ao mesmo <link> do Google Fonts de index.html, junto de "Bree Serif"/"Pinyon Script" já usadas ali.
Tema claro: o fundo do overlay (#121017, o valor fixo de --bg-canvas no tema escuro) e o texto do wordmark (.login-intro__letter/.login-intro__tagline, cor clara — pensados pra contrastar com um fundo escuro) só faziam sentido enquanto o overlay era sempre escuro (decisão original, pra login → animação → portal ler como uma coisa só). O usuário pediu que, no tema claro, o fundo da animação também acompanhasse o --bg-canvas claro (mesmo raciocínio já aplicado a .login-page em login.css) — o que por sua vez tornou o texto claro do wordmark ilegível contra um fundo claro. :root[data-theme="light"] overrides em login-intro.css cobrem os dois: .login-intro vira background: var(--bg-canvas) e .login-intro__letter/.login-intro__tagline viram cores escuras (#241c33/rgba(36, 28, 51, 0.62), os mesmos tons só invertidos). O ícone não precisou de override — o corpo roxo (#7b5ba8) tem contraste de sobra contra um fundo claro, e a "tela" do disquete (#241c33) já é escura por si só, então os olhos/sorriso continuam legíveis nos dois temas sem mudar nada. Nada disso afeta o tema escuro (padrão), que continua com os valores fixos originais.
Fluxo de navegação: auth.js, no sucesso do POST /api/auth/login/, chama pidPlayLoginIntro(() => { sessionStorage.setItem("pid_reveal_portal", "1"); window.location.href = "portal.html"; }) em vez de navegar direto — a navegação só acontece depois da animação inteira (não há como "pular" a animação hoje, nem foi pedido).
"A barra lateral surgindo da esquerda para a direita, e em seguida o resto da tela" (pedido explícito do usuário sobre como a tela Principal deveria surgir depois da animação, refinado duas vezes: a primeira versão só escondia .main-content e deixava a sidebar sempre visível desde o início, sem nenhuma entrada própria; a segunda versão deu à sidebar um fade + deslize sutil de 16px, considerado "ainda não satisfatório" — pequeno demais pra ler como "surgindo da esquerda pra direita"): static/js/portal-reveal.js (só portal.html, incluído defer logo depois de theme.js — mesmo padrão de "script que roda como IIFE de topo antes da primeira pintura pra evitar flash", ver "Ordem de <script>" acima) checa a sessionStorage marcada por auth.js; se presente, remove a marca (não sobrevive a um F5) e aplica duas classes em <html> antes do DOMContentLoaded: pid-entering-sidebar (esconde só .sidebar, opacity:0 + transform:translateX(-100%) — a largura inteira dela, não um deslize de poucos pixels, pra realmente ler como um slide de fora da tela) e pid-entering (esconde .main-content, o <div> que envolve topbar e conteúdo da página). As duas são removidas em sequência, não juntas: pid-entering-sidebar sai primeiro (120ms depois do DOMContentLoaded), a sidebar desliza da esquerda pra direita ao longo de 420ms (.sidebar em layout.css tem uma transition própria — opacity 420ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1), mais longa e com uma curva de "chegada" suave, não var(--transition-base) — genérica demais pra um movimento desse tamanho); só depois de esperar essa mesma duração (420ms) é que pid-entering sai, revelando o resto do app num fade, garantindo que as duas entradas não se sobreponham. Acessar portal.html direto (sem passar pelo login) nunca aciona nada disso, já que a sessionStorage só é setada no caminho de login bem-sucedido.
Testado pelo usuário em três rodadas (funcional) — ajustes até agora: a cor do overlay (era #241c33, virou #121017), o fade de entrada antes do ícone começar a se desenhar (não existia, overlay aparecia de repente; a duração foi ajustada depois de 350ms pra 500ms, "muito rápido... só pra ficar mais fluído"), a compressão da cena Portal (era 2,3s com pausa parada, foi pra 0,95s corrida numa rodada e depois ~0,9s), a aceleração de Build/Face (de 2,6s/2s cada pra 1s cada, Wordmark mantida em 2,8s de propósito) e a entrada da sidebar em portal.html (de um fade sutil de 16px pra um slide de fora da tela inteiro, translateX(-100%), 420ms). Ainda falta conferir o posicionamento do "voo" final em diferentes tamanhos de tela/com a sidebar colapsada.