120 KiB
Plano — Portal De Paula (Protótipo Interno)
Visão geral
Reformulação do portal interno da De Paula Contadores. O pedido original foi "estruturar um protótipo visual, mas funcional, para avaliarmos e estruturarmos melhor o frontend do portal" — reaproveitando a essência do portal atual (referência: capturas de tela do sistema em produção, tema escuro roxo/dourado, sidebar com dezenas de seções, busca de aplicações, cards de atalho, login com logo circular), não recriando do zero.
Decisões de arquitetura tomadas no início e válidas até a rodada 13:
- Stack: HTML + CSS + JS puro, sem framework, sem build step, sem dependência externa além das fontes do sistema.
- Sem backend: todo o estado (sessão, usuários, perfis de acesso,
favoritos, widgets, compromissos, tema) vivia no
localStoragedo navegador. - Visual: modernizar a estrutura existente (sidebar + topbar + busca + cards + login), não clonar pixel a pixel as capturas de referência.
Isso valeu como protótipo de validação de fluxo/desenho. Na rodada 13 o
usuário definiu a stack real de backend e pediu a migração completa — ver
essa seção para a arquitetura atual. Este documento foca no histórico de
decisões, no que foi construído em cada rodada e no que ainda está em
aberto; para arquitetura técnica atual (páginas, API, ordem de scripts,
modelo de permissões, como o CSS está dividido entre arquivos), ver
CLAUDE.md.
Cronologia de construção
1. Protótipo inicial — login + shell
Tela de login (logo, usuário/senha, "esqueceu senha" apontando para
Integração e Inovação) e o shell principal: sidebar com as 14 seções do
documento original de arquitetura, topbar com hamburger/"Acessar
Ramais"/notificações, busca de aplicações e uma grade de cards de atalho
fixos (Links, OS, Ramais, Relatórios, Calendário, Solicitações). Tema
claro/escuro com toggle salvo em localStorage. Um modal de "simular perfil
de acesso" (persona) deixava escolher entre as 7 personas do documento
original para ver o menu mudar — mecanismo removido depois (ver item 4).
2. Perfis de Acesso
Tela de administração (perfis-acesso.html) com lista + edição de perfis:
Código, Nome, árvore de permissões por módulo, aba "Usuários do Escritório".
Criado um perfil especial "Integração e Inovação", com acesso total e
uma capacidade exclusiva — gerenciaPermissoes — que também gate-ia o
acesso à própria tela de Perfis de Acesso.
3. Usuários
Tela de cadastro de contas (usuarios.html), movida para dentro do grupo
"Administração" no menu (junto de Perfis de Acesso), com a mesma restrição:
só quem tem gerenciaPermissoes acessa e pode cadastrar/editar/excluir
usuários.
4. Login real (fim do simulador de persona)
O usuário pediu para poder testar de verdade o cadastro de acessos — "podemos testar na realidade como irá funcionar". Isso trocou o mecanismo inteiro:
- Login parou de aceitar qualquer senha; passou a validar contra contas
reais em
pid_users_v1/v2(assets/js/users.js). - Duas contas de demonstração criadas:
gabriel/gabriel(perfil Integração e Inovação) ebruno/bruno(sem perfil vinculado, para testar o estado "sem acesso"). - O modal de simulação de persona foi removido; a visibilidade do menu
passou a vir do perfil de acesso de verdade vinculado ao usuário logado
(
access.js→pidResolveAccess). - Cada um dos 7 perfis do documento original ganhou um registro real em Perfis de Acesso (Diretoria, Departamento Pessoal, Gerencial, Legalização, Fisco/Contábil, Financeiro, Protocolo), mais Integração e Inovação (8 ao todo).
5. Múltiplos perfis por usuário
Pedido explícito: "algumas pessoas podem ter acesso ao perfil financeiro e
fisco/contábil". profileCodigo (número único) virou profileCodigos
(array) em pid_users; a resolução de acesso em access.js passou a fazer
união entre todos os perfis vinculados a um usuário — uma seção aparece
se qualquer um dos perfis do usuário conceder acesso a ela.
6. Bugs encontrados e corrigidos
Três bugs reais surgiram e foram corrigidos durante o uso:
users-admin.jsficou preso na assinatura antiga depidResolveAccess()({ profile }em vez de{ profiles }) depois da migração do item 5 — a tela de Usuários redirecionava sempre de volta pro Principal antes de renderizar, dando a impressão de "não abre". Corrigido ajustando a desestruturação.- CSS sobrescrevendo o atributo
hidden: componentes que definem seu própriodisplay(.no-access,.notif-badge,.app-card) ignoravam ohiddendo JS, porque uma regra de autor comdisplayexplícito vence a regra padrão do navegador[hidden]{display:none}mesmo com especificidade igual. A mensagem de "sem perfil vinculado" continuava aparecendo por cima do dashboard normal mesmo com o perfil já vinculado. Corrigido com uma regra global embase.css:[hidden] { display: none !important; }— resolveu esse caso e mais dois latentes (badge de notificação, filtro de busca nos cards). - Submenus recolhidos continuavam clicáveis/focáveis: o accordion do
sidebar só usava
max-height:0+overflow:hiddenpara esconder, o que não impede clique nem navegação por Tab. Corrigido adicionandovisibility:hidden+pointer-events:noneao estado fechado.
7. Permissão granular por aplicação
A árvore de permissões de cada módulo mostrava ações genéricas
(Visualizar/Incluir/Editar/Excluir), sem relação com o conteúdo real do
menu. Trocado por uma lista das aplicações reais de cada seção (ex.: em
Portais, os checkboxes viraram "Portal do Cliente" e "Portal Fiscal") —
e isso passou a funcionar de verdade: desmarcar uma aplicação esconde
só aquele item do sidebar (data-app + access.js), não o módulo inteiro.
Nesta mesma rodada, o campo Ambiente (Escritório/Empresa) foi removido
do cadastro de perfil — "trabalharemos como portal interno, sem mais de um
ambiente".
8. Favoritos
Pedido: "toda aplicação deve poder ser favoritada, para adicionar na tela
inicial". Implementado sem precisar marcar cada item do menu manualmente —
favorites.js deriva um ID estável a partir do próprio texto/estrutura do
sidebar (ver CLAUDE.md → "Favoritos") e injeta uma estrela ao lado de
cada aplicação. A tela Principal deixou de ter cards fixos (Links, OS,
Ramais...) e passou a ser inteiramente dirigida pelos favoritos de cada
usuário — o antigo cards.js foi removido.
9. Calendário De Paula → link externo
"O Calendário De Paula deve ser um redirecionador" para
https://depaula-tvcorporativa.lovable.app/calendario (abre em nova aba).
O calendário interno mockado (calendario.html + calendar.js, com
eventos fixos por setor) foi removido a pedido do usuário depois da troca —
não recriar sem confirmar.
10. Calendário Individual + Widgets
Pedido novo, em duas partes:
- Calendário Individual (
calendario-individual.html): agenda pessoal de cada colaborador (seção base, disponível a todo perfil). Cada um cria compromissos e pode marcá-los como visíveis para todos que compartilham pelo menos um perfil de acesso em comum (sharedWithProfile+pidEventsVisibleToemevents.js) — colegas só visualizam, não editam nem excluem compromisso alheio. - Widgets na tela Principal: área "Widgets" com botão "Adicionar
Widget" (seletor, no estilo Asana) — pensada para crescer além de um
único tipo. Hoje só existe o widget de Calendário Individual (próximos 5
compromissos), mas o registro
PID_WIDGET_TYPESjá está estruturado para novos tipos sem refazer a área de adicionar/remover.
11. Horário e edição no Calendário Individual
Compromissos ganharam campo de horário (ordenados por horário dentro do dia) e passaram a ser editáveis depois de criados — clicar num compromisso próprio abre o mesmo modal preenchido, com um botão "Excluir" adicional, em vez de excluir direto ao clicar.
12. Menu de conta
O chip do canto superior direito mostrava o perfil/departamento
("Departamento Pessoal" etc.) — trocado para mostrar o usuário logado
(nome + ícone de pessoa). Clicar abre um menu com nome + perfis vinculados,
"Alterar senha" (modal com senha atual/nova/confirmação, validada contra
pid_users) e, só para quem tem gerenciaPermissoes, um atalho para
Perfis de Acesso (antes era o clique direto no chip que levava pra lá).
Nesta rodada os estilos de campo de modal (.modal-field, .modal-actions
etc.), que só existiam em calendario.css, foram generalizados para
components.css, já que agora mais de uma tela precisa deles.
13. Migração para backend Django + PostgreSQL
O usuário definiu a stack real do backend: Python 3.13 + Django 6.0 + PostgreSQL 14, e pediu três coisas na mesma rodada:
- Construir esse backend completo (models, API REST, banco Postgres) — não só adaptar o frontend a um contrato assumido.
- Migrar para o banco tudo que hoje existia no
localStorage, exceto a preferência de tema: usuários, perfis de acesso, favoritos, widgets e compromissos do Calendário Individual. - Autenticação via sessão/cookie do Django (decisão explícita, não token/JWT) — o que levou à escolha de servir o frontend estático pelo próprio Django (mesma origem), evitando complicação de cookie de sessão cross-origin.
O que mudou estruturalmente:
- Projeto Django novo, inicialmente em
Portal/backend/(config/+ app únicoportal_api/) — reorganizado na rodada 14 para o padrão de pastas de um projeto Django de verdade. VerCLAUDE.md→ "Arquitetura" para o detalhamento de cada arquivo e a tabela de endpoints. - Model de usuário customizado (
Usuario, estendendoAbstractUser) desde o início — trocar depois de criado o projeto é doloroso. - Senha passou a usar hashing nativo do Django (
set_password/check_password) — antes era texto puro no protótipo; melhoria de segurança real, não só troca de armazenamento. - Catálogo de módulos/aplicações do menu (antes
PID_MODULES/PID_MODULE_APPShardcoded emprofiles.js) virouportal_api/catalogo.py, fonte única da verdade, exposto só leitura viaGET /api/catalogo/. - A união de permissões entre múltiplos perfis de um usuário (rodada 5),
antes calculada no cliente (
access.js→pidResolveAccess/pidApplyAccessVisibility), passou a ser calculada uma única vez no servidor (permissoes_efetivas()emviews.py) e consumida pronta viaGET /api/me/. - A lógica de "quem vê qual compromisso" do Calendário Individual (rodada
10 —
pidEventsVisibleTo) também migrou pro backend (CompromissoAgendaViewSet.get_queryset). - Todo o frontend que lia/escrevia
localStoragefoi reescrito paraasync/fetchcontra a API (assets/js/api.js, hojestatic/js/api.js— ver rodada 14 — é o módulo novo), mantendo a mesma estrutura de arquivos e nomes de função sempre que possível para minimizar o tamanho do diff. assets/js/users.js(armazenamento local de usuários) foi removido — usuários agora só existem no banco, via/api/usuarios/.
Limitação do ambiente onde essa rodada foi feita: só havia Python 3.8 e
nenhum servidor PostgreSQL disponíveis, então não foi possível rodar
makemigrations/migrate/runserver nem testar o fluxo ponta a ponta
durante a implementação — o código foi revisado estaticamente com cuidado
(sintaxe, convenções do DRF, coerência entre serializers/views/frontend).
Rodar de verdade — incluindo gerar a migração inicial — fica para o
ambiente com Python 3.13 + Postgres 14.
14. Reorganização de pastas no padrão Django
O backend tinha sido criado numa subpasta Portal/backend/, com o
frontend (HTML/CSS/JS) solto na raiz de Portal/ ao lado dela — funcional,
mas não era a estrutura convencional de um projeto Django. Pedido: "reorganize
a estrutura das pastas, conforme o padrão de um projeto Django". Mudanças:
manage.py,requirements.txt,.env,config/eportal_api/subiram dePortal/backend/para a raiz dePortal/—Portal/passou a ser o projeto Django, não conter um subprojeto.- As 5 páginas HTML foram movidas para
Portal/templates/(antes soltas na raiz) — é para lá queTEMPLATES[0]["DIRS"]aponta agora. assets/css,assets/jseimg/viraramstatic/css,static/jsestatic/img(pastaassets/removida) —STATICFILES_DIRS(novo emsettings.py) aponta pra lá, eSTATIC_ROOTfoi adicionado paracollectstaticem produção.- Os 5 templates passaram a usar
{% load static %}+{% static 'css/x.css' %}em vez de caminhos hardcoded (assets/css/x.css) — forma idiomática do Django de referenciar estáticos. config/urls.pyperdeu osre_path/static_servemanuais paraassets//img/—django.contrib.staticfilesjá servestatic/automaticamente emDEBUGa partir deSTATICFILES_DIRS, sem configuração extra.- Bug corrigido de passagem: o
.envjá existia (com as credenciais do Postgres) massettings.pynunca chamavaload_dotenv()— ele nunca tinha sido lido de verdade. Corrigido ao mexer emsettings.pypor causa da mudança deBASE_DIR.
Nenhuma mudança de comportamento — só de organização de arquivos; os endpoints da API, os models e a lógica do frontend continuam os mesmos da rodada 13.
15. Dispensar notificações persiste por usuário
Bug relatado: ao clicar no X de uma notificação individual, o card do dropdown fechava inteiro (comportamento incorreto) e, além disso, qualquer notificação dispensada (X individual ou "Limpar tudo") voltava a aparecer depois de um reload da página.
- Fechamento indevido do dropdown: causado pela ordem de eventos —
list.innerHTMLera reconstruído (removendo o botão clicado do DOM) antes do clique terminar de subir até o listener global emdocumentque fecha o dropdown em clique fora; como o botão já não estava mais na árvore,dropdown.contains(event.target)avaliava falso, fechando o card como se fosse clique externo. Corrigido comevent.stopPropagation()no handler de dismiss emnotifications.js. - Notificação reaparecendo no reload: era o comportamento documentado
até então (
notifications.jsguardava a lista só em memória, "zera a cada reload") — o usuário pediu explicitamente para persistir no backend em vez de usarlocalStorage. Criado o modelNotificacaoDispensada(chave naturalusuario+notif_id, mesmo padrão deFavorito/WidgetUsuario) e o endpoint/api/notificacoes-dispensadas/.notifications.jsagora busca os IDs já dispensados no carregamento e filtra a lista antes de renderizar, e persiste via POST a cada X clicado ou "Limpar tudo". Importante: só o estado de dispensada passou a ser real — o conteúdo das notificações de "nova ferramenta" (PID_NEW_TOOLS_NOTIFICATIONS) continua mockado/hardcoded no frontend, só os itens de compromisso vêm de dados reais.
16. Links & Ferramentas — tela nova + permissão de edição dedicada
Pedido: estruturar de verdade a tela "Links & Ferramentas" (até então só um
item de menu com href="#", sem página própria), com base num print da
ferramenta antiga (grade de cartões com logo/nome de sistemas externos —
Ottimizza, Sieg, GLPI, Trello, WhatsApp etc., cada um abrindo o link
correspondente). Três exigências específicas:
- Edição restrita por permissão de perfil: só perfis com uma permissão
dedicada podem reordenar, incluir e remover os cartões — hoje só
habilitada para "Integração e Inovação" (
gerencia_links_ferramentas=Trueno seed), mas com o toggle já disponível em Perfis de Acesso pra liberar outros perfis depois, sem precisar de código novo. - Adicionar link: modal com Nome, Link (URL) e "uma foto ou ícone" — interpretado como upload de imagem real (não uma URL de ícone nem um emoji/picker), já que o print de referência mostra logos de marca de cada ferramenta.
- Nada além disso foi pedido — não existe hoje edição de nome/URL/ícone de um link já criado pela UI (só admin do Django), por decisão de manter o escopo no que foi pedido (adicionar/remover/reordenar).
O que foi construído:
- Nova permissão no mesmo padrão de
gerencia_permissoes: campogerencia_links_ferramentas(BooleanField) emPerfilAcesso, união emUsuario.gerencia_links_ferramentas(), classePodeGerenciarLinksFerramentasempermissions.py, exposta emme.gerencia_links_ferramentas. É uma flag separada do toggle de visibilidade do módulo "Links & Ferramentas" (que já existia no catálogo, controla só se o item aparece no menu) — uma controla quem vê a tela, a outra quem pode editar o conteúdo dela. Adicionado um checkbox dedicado na aba Permissões deperfis-acesso.html(#pa-gerencia-links-ferramentas), fora da árvore de módulos/apps — hoje é o único toggle "especial" desse tipo com UI própria (gerencia_permissoesem si só é setável via seed/admin, sem checkbox equivalente ainda; não alterado nesta rodada por não ter sido pedido). - Model
LinkFerramenta: lista global (sem FK de usuário, ao contrário de Favorito/WidgetUsuario) — todo mundo vê os mesmos cartões. Campoordem(inteiro) para a sequência de exibição;iconeéImageFieldopcional (exigiu adicionar Pillow aorequirements.txte configurarMEDIA_URL/MEDIA_ROOT, que não existiam no projeto até agora — servidos emDEBUGporconfig/urls.py, sem equivalente em produção ainda configurado além do padrão whitenoise/nginx já usado praSTATIC_ROOT). - Reordenação sem endpoint de lote: mover um cartão faz duas chamadas
PATCH trocando o
ordemde dois itens adjacentes — decisão deliberada de não criar um endpoint de bulk-reorder nem usar drag-and-drop (sem biblioteca no projeto, drag-and-drop nativo teria custo de acessibilidade/ touch maior que o ganho); a UI usa duas setas (mover pra cima/baixo) por cartão, visíveis só pra quem tem a permissão. - Upload multipart:
pidApiRequest(api.js) só sabia enviar JSON — estendido para detectarbody instanceof FormDatae, nesse caso, deixar o browser montar oContent-Type: multipart/form-datacom boundary sozinho (sem isso, o upload do ícone quebraria). - Página nova
links-ferramentas.html, réplica do shell padrão (mesma sidebar/topbar dos outros 5 templates) +links-ferramentas.js+links-ferramentas.css; item do menu "Links & Ferramentas" trocou dehref="#"prahref="links-ferramentas.html"nos 5 templates que replicam a sidebar.
Mesma limitação de ambiente das rodadas anteriores: sem Python 3.13/Postgres
neste ambiente, não foi possível rodar makemigrations/migrate (novo
model + dois campos novos em PerfilAcesso) nem testar upload de imagem de
ponta a ponta.
17. Ambiente ganhou Python 3.13 + Postgres — migração da rodada 16 aplicada
O usuário avisou que "todas as ferramentas necessárias já estão instaladas"
neste ambiente. Confirmado: o .venv do projeto já tem Django 6.0.7, DRF
3.17.1, psycopg, python-dotenv e Pillow 12.3.0 (Python 3.13.14), e há um
Postgres local acessível pelas credenciais do .env — inclusive já com
migrações antigas aplicadas até 0002_notificacaodispensada (rodada 15),
de uma sessão anterior fora deste histórico. A limitação documentada nas
rodadas 13–16 (só Python 3.8, sem Django, sem Postgres) não vale mais —
CLAUDE.md foi corrigido pra refletir isso.
Com o ambiente disponível, gerada e aplicada a migração pendente da rodada
16 (0003_linkferramenta_and_more: model LinkFerramenta + campo
gerencia_links_ferramentas em PerfilAcesso) e reaplicado seed_portal
— gerencia_links_ferramentas=True confirmado no perfil "Integração e
Inovação". Ainda não testado num navegador de verdade (login +
upload de ícone + reordenar + notificações persistindo entre reloads) —
próximo passo natural se o usuário quiser essa validação.
18. Bug visual: espaçamento inconsistente na sidebar
Reportado com print: itens do menu com estrela de favorito (qualquer
<a class="nav-item">/.nav-subitem, exceto "Principal") pareciam ter
espaçamento diferente dos itens sem estrela (toggles de grupo como
Auditorias/Solicitações, que são <button>, não <a>, e por isso nunca
ganham estrela via pidCollectFavoritableApps). Causa raiz: .nav-item-row
(o wrapper que favorites.js injeta em volta de um link favoritável pra
acomodar a estrela) não tinha padding-right nenhum, então o .fav-toggle
ficava encostado na borda direita da sidebar — enquanto o chevron dos
toggles de grupo mantinha os 12px de padding-right do próprio .nav-item.
Corrigido com padding-right: var(--space-3) em .nav-item-row
(layout.css), replicado nos dois estados responsivos (sidebar colapsada
no desktop, sidebar recolhida no mobile) pra não descentralizar o ícone
quando a estrela está escondida.
19. Permissão de Links & Ferramentas: de checkbox dedicado para visualizar/editar na árvore
A permissão gerencia_links_ferramentas (criada na rodada 16 como
BooleanField dedicado + checkbox solto no topo do card de edição de
perfil) foi revertida a pedido do usuário: "ao invés de ter uma caixa de
seleção acima, deixar uma opção onde marca a liberação para visualizar o
Links & Ferramentas, com subseleções entre visualizar e editar. Pois
teremos outras aplicações com a mesma funcionalidade." Ou seja: o pedido não
era só um ajuste de UI, era um pedido de modelo de dados reutilizável
pra qualquer módulo futuro que precise da mesma distinção visualizar/editar.
Novo desenho (documentado em detalhe em CLAUDE.md → "Padrão
visualizar/editar"): em vez de um campo dedicado por módulo, "visualizar" e
"editar" passaram a ser dois apps normais de links-ferramentas em
catalogo.MODULE_APPS — reaproveitando 100% a árvore de permissões
genérica que já existia (profiles.js), sem nenhum código de UI novo. Isso
também tornou o backend mais estrito: antes, GET /api/links-ferramentas/
era liberado a qualquer autenticado; agora exige apps.visualizar, e a
escrita exige apps.editar — ambos checados por uma única classe genérica
PermissaoApp(module_key, app_key) (permissions.py) reaproveitável por
qualquer módulo futuro com a mesma necessidade, sem precisar de subclasse
nova.
Removido nesta rodada: campo gerencia_links_ferramentas em PerfilAcesso
(migração 0004_remove_perfilacesso_gerencia_links_ferramentas), método
Usuario.gerencia_links_ferramentas(), classe PodeGerenciarLinksFerramentas,
o campo em PerfilAcessoSerializer/PerfilResumoSerializer/me_view, e o
checkbox #pa-gerencia-links-ferramentas em perfis-acesso.html/profiles.js.
Pegadinha resolvida no seed_portal.py: como links-ferramentas está em
BASE_KEYS (habilitado pra todo perfil), catalogo.permissions_from_keys()
habilitaria todos os apps do módulo de uma vez — incluindo "editar" pra
todo mundo. Corrigido forçando apps.editar = False explicitamente pra
qualquer perfil que não seja "Integração e Inovação" (código 8), depois de
montar o dict de permissões. Confirmado via shell: os 7 perfis "normais"
saíram com visualizar=True, editar=False; só o código 8 saiu com ambos
True.
Migração 0004 gerada e aplicada no ambiente local (Python 3.13/Postgres
disponíveis desde a rodada 17); manage.py check limpo.
20. Logo com texto branco + sidebar maior
Dois bugs visuais de marca reportados com print: (1) a logo da tela de
login (logo.png) tem o texto "DePaula Contadores" em preto, ilegível
contra o fundo escuro do card de login (--bg-surface); (2) a logo da
sidebar (logo-mono.png, versão toda em branco/cinza claro) estava
pequena demais (.sidebar__logo era 56×56 fixo, espremendo uma arte que é
bem mais larga que alta) — pedido explícito pra usar ali uma versão com o
D colorido e a letra branca, num tamanho maior.
Essa variante (D colorido + texto branco) não existia como arquivo — só
existiam logo.png (D colorido, texto preto) e logo-mono.png (tudo
branco, D incluído). Gerada uma nova, logo-branco.png, com um script
Python usando Pillow (ambiente já tinha Pillow desde a rodada 16): varre
logo.png pixel a pixel e recolore pra branco só os opacos
quase-neutros/escuros (max(r,g,b) < 70 e spread(r,g,b) < 12) — o
degradê dourado/marrom do D nunca bate nesse filtro porque mesmo na sombra
mais escura mantém um matiz quente (testado: pixel mais escuro do D
amostrado foi (80,65,42), spread 38; texto era (0,0,0) puro, spread 0).
572.209 pixels alterados. Resultado confirmado visualmente (Read da imagem
gerada) antes de usar.
Trocado logo.png → logo-branco.png no login (index.html) e
logo-mono.png → logo-branco.png nos 5 shells (mesmo arquivo nos dois
lugares — ambos os fundos são escuros, então a mesma variante serve para
os dois). .sidebar__logo foi de 56×56 fixo pra width:200px; height:auto (a arte é ~1.41:1, largura bem maior que altura — forçar
quadrado a esmagava); adicionado encolhimento pra 44px nos dois estados
de sidebar colapsada (.is-collapsed no desktop, breakpoint mobile) pra
não vazar da faixa de 76px.
logo.png e logo-mono.png continuam no repo (não usados em nenhum
template agora) — o primeiro como fonte pra regerar logo-branco.png se a
arte oficial mudar, o segundo como variante alternativa disponível.
21. Ramais — tela nova
Pedido: reconstruir de verdade a tela "Ramais" (até então só um item de menu com href="#" e um botão "Acessar Ramais" sem destino em todos os shells), com base em prints da ferramenta antiga: diretório de ramais alimentado pelo cadastro real de usuário (Nome/Departamento/Ramal), modal de "Adicionar um Novo Ramal" (com opção de vincular um usuário existente ou criar uma linha avulsa "Não Tem Usuário"), botão "Novo Chamado", botão "Criar Ausência" e cartões especiais para colaborador ausente/aniversariante. Três decisões confirmadas com o usuário antes de implementar:
- Novo Chamado abre embutido na própria tela (modal com
<iframe>apontando pro token da ferramenta de chamados), não em nova aba — diferente do padrão de todo outro link externo do portal. - Editar um ramal vinculado a usuário: só o número do Ramal é gravável nesta tela — grava direto em
Usuario.ramal; Nome/Departamento continuam só leitura do cadastro. - Ausência: "(AUSENTE)" é calculado automaticamente comparando a hora atual com o período criado, mais uma ação de "Encerrar Ausência" pra quem volta antes do previsto.
- Férias: fora de escopo — o próprio usuário disse que depende de uma integração futura com outro banco; nenhuma UI de férias foi construída.
O que foi construído (detalhe técnico completo em CLAUDE.md → seção "Ramais"):
- Dois models novos:
Ramal(linha da tela, opcionalmente vinculada aUsuario— vinculada, os dados vêm sempre do cadastro; avulsa,nome/departamento/numeromoram na própria linha) eRamalAusencia(esta_ativa()calcula "ausente agora" comparando datas/horas, sem armazenar um flag;encerrada_manualmentepermite encerrar antes do previsto sem mexer no período original). - Mesmo padrão visualizar/editar da rodada 19 (Links & Ferramentas):
ramaisganhou os dois apps na árvore de permissões, com o mesmo cuidado noseed_portal.pyde forçareditar=Falsepra todo perfil que não seja Integração e Inovação (já queramaisestá emBASE_KEYS,permissions_from_keys()habilitaria os dois de uma vez sem esse override). - Endpoint dedicado
GET /api/ramais/usuarios/pra alimentar o<select>"Lista de Usuários" dos modais, em vez de reaproveitar/api/usuarios/— aquele é restrito agerencia_permissoes, e a permissão de Ramais foi mantida deliberadamente desacoplada disso (mesmo espírito da própria refatoração visualizar/editar da rodada 19). - Página nova
ramais.html+ramais.js+ramais.css, réplica do shell padrão, reaproveitando a tabela genérica.pa-table*deperfis-acesso.css(mesmo padrão queusuarios.htmljá usa sem CSS de tabela próprio). Nos outros 5 templates, o item de menu "Ramais" (anteshref="#") e o botão "Acessar Ramais" do topbar (antes um<button>sem handler nenhum) passaram a apontar de verdade praramais.html. - Migração
0012_ramal_ramalausenciagerada e aplicada no ambiente local;seed_portalreaplicado (confirmado via shell: 7 perfis comramais.apps.editar=False, só "Integração e Inovação" comTrue).
Ainda não testado num navegador de verdade — próximo passo natural se o usuário quiser essa validação (ver "Roadmap" abaixo).
22. Ramais: colaboradores aparecem automaticamente, sem precisar "adicionar"
Depois de testar a rodada 21 no navegador (print anexado: só Gabriel aparecia na lista, porque ele tinha se "adicionado" manualmente pelo modal), o usuário pediu duas coisas:
- "Pode trazer automaticamente o cadastro do usuário para esta tela de ramais, ficando apenas pendente o cadastro de ramal, se necessário" — ou seja, o modelo original (rodada 21), que exigia criar uma linha
Ramalvinculada a umUsuariopra alguém aparecer no diretório, criava um passo manual desnecessário: todo colaborador com conta no Portal já deveria aparecer sozinho, com o ramal pendente até ser preenchido. - "Melhore a visualização da opção de adicionar ramal, o ícone de seleção está feio e destoa da página" — o
<select>nativo do browser (sem nenhum CSS) destoava do tema escuro do resto do portal.
O que mudou:
Ramalperdeu o campousuario(migração0013_remove_ramal_usuario_alter_ramal_nome) — agora é só linha avulsa (sem conta de sistema por trás, ex.: telefone de sala), comnomeobrigatório edepartamento/numeroopcionais (podem ficar pendentes, igual a um colaborador de verdade).RamalViewSet.list()foi reescrito pra mesclar duas fontes numa lista só, sem passar peloRamalSerializer: todoUsuarioativo (linha montada direto do cadastro —nome,departamentos,ramal) + as linhas avulsas deRamal. Cada item ganha umidsintético ("usuario-<id>"/"avulso-<id>") e umtipo, que o frontend usa pra decidir a ação certa.- Editar o ramal de um colaborador de verdade deixou de ser "criar uma linha vinculada" — agora é a nova action
PATCH /api/ramais/usuarios/{usuario_id}/(atualizar_ramal_usuario), que grava direto emUsuario.ramal. "Adicionar Ramal" ficou só para linhas avulsas — o<select>"Lista de Usuários" que existia nesse modal foi removido (não faz mais sentido, já que o colaborador já aparece sozinho); só o modal de Criar Ausência ainda tem esse<select>(viaGET /api/ramais/usuarios/, agora simplificado pra sóid/nome). - Excluir só existe pra linhas avulsas agora (não dá pra "excluir" um colaborador do diretório sem excluir a conta dele, o que é escopo da tela de Usuários) — o ícone de lixeira some das linhas de usuário de verdade.
- Usuário inativo não aparece mais no diretório (
list()filtrais_active=True) — efeito colateral da reescrita que corrige uma limitação que a rodada 21 tinha deixado em aberto. - Select/textarea estilizados:
components.cssganhou.modal-field select/.modal-field textareagenéricos (seta customizada viabackground-image, sem oappearancenativo do browser) — não existia nenhum estilo pra esses dois elementos antes (sóinput), então qualquer modal futuro com<select>/<textarea>já sai consistente com o tema escuro sem precisar de CSS próprio.
Pegadinha resolvida durante a migração: como o campo usuario foi removido por completo (não só esvaziado), a única linha Ramal vinculada que já existia (a que o usuário tinha criado testando a rodada 21, ligada à própria conta dele) ficou órfã — virou uma linha avulsa com todos os campos em branco, porque a informação de qual usuário ela representava dependia só da FK removida. Identificada e apagada manualmente depois da migração (não tinha dado nenhum pra preservar, já que os três campos ficavam vazios pra linhas vinculadas no desenho antigo); o ramal do usuário de teste bruno também tinha sido alterado por um smoke test rodado antes desta correção e foi restaurado ao valor original. Sem consequência pra dado real, já que a tela nunca tinha sido usada em produção — mas fica registrado porque é o tipo de coisa que merece atenção se acontecer de novo com dado que importe.
Limitações conhecidas / decisões assumidas
- Calendário De Paula (empresa) não tem mais versão interna — depende
inteiramente do link externo
depaula-tvcorporativa.lovable.app. - Widgets: só um tipo implementado (Calendário Individual) até agora.
- Sem teste automatizado (nem no frontend, nem no backend Django) — todo o
fluxo foi validado manualmente no navegador. As rodadas 13 e 14 não
puderam ser testadas ponta a ponta no ambiente da época; a partir da
rodada 17 o ambiente já roda
migrate/runserverde verdade (ver acima), mas o teste manual no navegador de Links & Ferramentas (rodada 16) e da persistência de notificações (rodada 15) ainda não foi feito. - Tema (claro/escuro e cor) continua só no
localStorage— é preferência de navegador, decisão deliberada de manter fora da migração para o banco. - Links & Ferramentas: não dá pra editar nome/URL/ícone de um link já criado pela UI, só adicionar/remover/reordenar (o que foi pedido na rodada 16) — editar um existente hoje é só via Django admin.
- Ramais: sem UI de férias (integração futura com outro banco, combinado com o usuário na rodada 21); excluir um colaborador do diretório não é possível por ali (só linhas avulsas podem ser excluídas) — é escopo da tela de Usuários, por decisão da rodada 22.
- Simulação de Custo de Contratação (rodada 37): v1 cobre só Empregado CLT — as outras 4 modalidades do pedido original ficam para quando houver uma planilha/regra de referência validada pelo contador.
- Indicador de Desempenho (rodada 38): v1 cobre só o Fiscontábil — outros departamentos ficam para rodada futura; sem visão própria do colaborador no Portal (recibo é documento interno do RH).
23. Ramais: ajustes visuais + visualizar/editar/excluir ausência
Três pedidos pequenos, feitos em sequência depois de testar a rodada 22 no navegador:
- Overflow no modal de Adicionar Ramal:
.modal-field-rowusavagrid-template-columns: 1fr 1frsemminmax(0, ...), e os inputs de.modal-fieldnão tinhamwidth: 100%explícito — o conteúdo (tamanho intrínseco do<input>) empurrava a grade além da borda do modal. Corrigido emcomponents.css(grid-template-columns: minmax(0, 1fr) minmax(0, 1fr)+width: 100%eminput/select/textareade.modal-field) — bug genérico, vale pra qualquer modal do portal com campos lado a lado, não só Ramais. - Modal "Novo Chamado" cortando conteúdo: a ferramenta externa embutida no iframe (rodada 21) é mais alta que um modal padrão; o iframe tinha altura fixa (
min(70vh, 640px)) menor que o conteúdo, cortando o formulário no meio de um campo em vez de rolar de forma previsível. Corrigido emramais.css:.ram-chamado-cardpassou a ocupar quase a tela inteira (min(960px, 95vw)×min(92vh, ...), o teto em px ajustado depois pelo próprio usuário direto no CSS) e o iframe virouflex:1dentro do.modal-card(que já é flex-column), preenchendo todo o espaço vertical restante. - Visualizar/editar/excluir ausência: o usuário passou um print de como devia ficar — clicar no badge "(AUSENTE)" de uma linha abre um modal "Visualizar Ausência" com os campos do período (desabilitados) e dois botões, "Editar Ausência" e "Deletar Ausência". Implementado reaproveitando o
RamalAusenciaViewSetque já existia (nenhuma mudança de backend —GET/PATCH/DELETEem/api/ramais-ausencias/{id}/já funcionavam, só faltava a UI): o badge virou um<button data-ram-ver-ausencia>que busca o registro viaGETe abre o modal; "Editar" habilita os mesmos campos in-place (sem modal novo) e troca os botões por "Cancelar"/"Salvar Ausência"; "Deletar" remove o registro de verdade. Isso substituiu o botão "Encerrar Ausência" da rodada 21 (que só marcavaencerrada_manualmente=True, preservando o registro) — o campo continua no model, mas sem UI própria; editar/excluir cobre o caso de uso real de forma mais direta, do jeito que foi demonstrado.
Nenhuma migração nova (mudança 3 não tocou o backend). manage.py check limpo depois das mudanças 1 e 2 (não alteraram Python, só confirmado por precaução).
24. Ramais: contraste dos indicadores de ausente/aniversariante
Prints em claro e escuro mostrando as linhas de Bruno (ausente) e Willian (aniversariante) — pedido pra melhorar a legibilidade. Perguntado antes de mexer, porque "observações" no pedido original não batia com nenhum print (nenhum mostrava o campo Observações do modal de Ausência); o usuário confirmou que o problema era o tingimento de fundo da linha inteira (.ram-row--ausente td/.ram-row--aniversario td, rodada 21) somado à cor do próprio selo — duas camadas de cor translúcida da mesma matiz empilhadas, contraste ruim nos dois temas.
Primeira tentativa: removido o tingimento da linha inteira, deixando só o selo comunicar o status (mesmo padrão do selo "Pendente"). O usuário pediu de volta o tingimento — "quero que deixe o tingimento, porém que fique num tom mais forte, para facilitar sua visualização". Desenho final: .ram-row--ausente/.ram-row--aniversario td voltaram (com opacidade maior que a versão original da rodada 21) e .ram-badge--ausente virou um selo (pill) de verdade, igual ao de aniversariante — as duas coisas juntas, cada uma com cor de texto/fundo ajustada por tema via :root[data-theme="light"] (mesmo padrão de override que tokens.css já usa) em vez de reaproveitar --danger/--gold crus, que não tinham contraste suficiente pensados pra texto pequeno sobre um selo ou fundo de linha.
25. Favicon
Pedido: "no ícone da aba do navegador, deve constar esta logo minimalista do escritório" — o usuário passou um print de referência: só o D (o mesmo pen/quill em degradê dourado/marrom de logo.png), sem o texto "De Paula Contadores", sobre fundo transparente.
Esse recorte isolado do D não existia como arquivo — as três variantes de static/img/ sempre têm o texto junto. Gerado favicon.png a partir de logo.png reaproveitando o mesmo filtro criado na rodada 20 pra logo-branco.png (pixel opaco quase-neutro/escuro = texto, nunca o D, que mantém matiz quente até na sombra mais escura do degradê) — mas em vez de recolorir esses pixels pra branco, apagados (alpha=0) pra isolar só o D; resultado recortado pelo bounding box do que sobrou e centralizado num canvas quadrado transparente (o D é mais alto que largo), redimensionado pra 192×192. Resultado comparado visualmente (Read da imagem gerada) contra o print do usuário antes de usar — bateu.
Ligado via <link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}"> no <head> das 7 páginas (index.html + os 5 shells + ramais.html). Nenhuma mudança de Python; manage.py check limpo por precaução.
26. Bug: busca de aplicações deixava grupos do menu abertos depois de limpar
Reportado com print: depois de pesquisar em "Pesquise por Aplicação..." (search.js) e apagar a busca, os grupos do menu (Portais, Geradoc etc.) que a busca tinha aberto pra mostrar o resultado continuavam abertos, em vez de voltar ao estado de antes de buscar.
Causa: search.js só tinha o caminho de abrir um nav-group (classList.add("is-open")) quando ele dava match durante a digitação — nunca fechava de volta quando o termo era apagado. Corrigido guardando, no início de cada sessão de busca (primeira tecla digitada), quais grupos já estavam abertos manualmente (gruposAbertosAntesDaBusca, um Set); ao limpar o campo, cada grupo volta exatamente pro estado daquele snapshot, em vez de simplesmente remover is-open de todo mundo (o que fecharia até um grupo que o usuário tinha aberto manualmente antes de buscar). Só front-end (static/js/search.js), nenhuma mudança de backend.
27. Botão "Criar Ausência" fora do padrão + texto do menu no tema claro
Dois ajustes pequenos:
- Botão "Criar Ausência" (
ramais.html) usavabtn-outline, destoando dos outros dois botões da mesma barra de ações ("Adicionar Ramal", "Novo Chamado"), ambosbtn-solid. Trocado prabtn-solid, sem nada mais mudar no comportamento. - Texto do menu no tema claro: a sidebar é intencionalmente congelada (fundo sempre escuro nos dois temas, ver
CLAUDE.md→ "CSS — organização entre arquivos"), mas as variáveis de texto (--sidebar-text-primary/--sidebar-text-secondary/--sidebar-text-muted) nunca tinham sido sobrescritas pro tema claro — pedido explícito pra virarem branco puro nesse tema, pra melhorar a legibilidade contra o fundo que continua escuro. Adicionada uma exceção deliberada emtokens.css(:root[data-theme="light"]) só pra essas três variáveis; fundo/borda da sidebar continuam frozen como antes.CLAUDE.mdatualizado pra documentar a exceção, já que a regra geral (não usar overrides de tema na sidebar) continua valendo pro resto.
28. Fonte "Bree Serif" no título da tela inicial
Pedido: trocar a fonte do texto "Portal De Paula" no topbar da tela inicial (portal.html) pra Bree Serif, serif, negrito — só ali, não em --font-sans (usada em todo o resto do portal) nem no .topbar__title dos outros shells (que mostram outros títulos, como "Ramais"/"Administração").
Primeira fonte externa do projeto — até aqui só fontes do sistema (--font-sans). Carregada via Google Fonts (<link rel="preconnect"> + <link ... family=Bree+Serif>) no <head> de portal.html. Estilo aplicado num id novo, #portal-title (no mesmo <h1 class="topbar__title"> de sempre, só ganhou o id), colocado em widgets.css por ser o único CSS próprio da página — mesmo não sendo um widget. Ajustado por iterações diretas do usuário logo em seguida: negrito removido, tamanho aumentado (1.3rem), negrito devolvido, negrito removido de novo — estado final é Bree Serif 400/1.3rem. Na mesma leva de ajustes, o usuário pediu a mesma fonte também no título "Portal De Paula" da tela de login (.login-card__title, login.css) — index.html ganhou o mesmo <link> do Google Fonts.
29. Animações — pedido explícito pra ignorar prefers-reduced-motion
Pedido: "inclua animações nas páginas, para deixá-las fluidas", com duas condições explícitas — rápidas ("otimizando o tempo") e que ignorem a preferência de acessibilidade do sistema operacional/navegador do usuário (prefers-reduced-motion). Isso é uma inversão deliberada da prática padrão de acessibilidade (que normalmente desativaria animação quando essa preferência está ativa) — decisão do dono do produto pro próprio portal interno, registrada aqui pra não ser "corrigida" de volta sem confirmar de novo.
Implementado com três @keyframes genéricos em base.css (pidFadeIn, pidFadeSlideUp, pidScaleIn — único CSS carregado por toda página, inclusive index.html), aplicados via animation (não transition, que não anima a troca display:none ↔ visível causada por hidden) em: .modal-overlay/.modal-card (todo modal do app), .account-dropdown/.notif-dropdown, .page-content (toda navegação entre páginas) e .login-card. Botões (.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline/.icon-btn) ganharam transform: scale() no :active como feedback de clique, e .pa-table td (reaproveitada por Perfis de Acesso/Usuários/Ramais) ganhou transition: background no hover da linha, que antes trocava sem transição nenhuma. Durações todas curtas (120–200ms, a maioria usando os tokens --transition-fast/--transition-base que já existiam). Nenhum @media (prefers-reduced-motion: reduce) foi adicionado — ausência deliberada, não descuido (ver CLAUDE.md → "Animações").
30. "Acessar Ramais" vira modal de consulta rápida, em vez de navegar
Pedido, com print de referência do portal antigo: o botão "Acessar Ramais" do topbar (presente em portal.html, links-ferramentas.html e calendario-individual.html — só essas 3 páginas têm o atalho) não deveria mais levar pra ramais.html; deveria abrir um modal com a lista de ramais cadastrados, com busca por nome/departamento/ramal. O print de referência mostrava um estilo DataTables (branco/azul, "Show N entries", colunas ordenáveis, paginação numerada) — decisão de modernizar a funcionalidade pro visual escuro/roxo do resto do portal, não clonar pixel a pixel (mesmo critério já usado desde a rodada 1).
O que foi construído:
#ramais-btnvoltou a ser um<button>(não<a href="ramais.html">, que era o estado desde a rodada 21) nas 3 páginas.- Modal novo (
#ramais-lookup-modal, duplicado nas 3 páginas — mesmo padrão de outros modais compartilhados como o de senha) +static/js/ramais-lookup.js+static/css/ramais-lookup.css: busca numa caixa só (filtra as três colunas ao mesmo tempo, mais simples que a busca em duas caixas da telaramais.html), ordenação por coluna (clique no cabeçalho alterna asc/desc) e paginação (10/25/50/100 por página) — tudo client-side, sem endpoint novo, reaproveitandoGET /api/ramais/(a mesma listagem mesclada usuário+avulso da tela completa), buscado uma vez por abertura de página. - Gate por permissão: o botão some (
hidden) se o perfil do usuário não tiverapps.visualizaremramais— mesmo padrão do resto do app. - Botão "Ir para Controle de Ramais" no rodapé do modal é o link de verdade pra
ramais.html(tela com edição); o modal em si é só consulta. - Pegadinha resolvida: a tabela do modal não podia reaproveitar
.pa-tabledeperfis-acesso.cssporqueportal.html/links-ferramentas.html/calendario-individual.htmlnão carregam esse CSS (sóramais.html/usuarios.html/perfis-acesso.htmlcarregam) —ramais-lookup.cssficou com estilo de tabela autocontido (.ram-lookup-table*) em vez de depender de um CSS que a página não tem.
Nenhuma mudança de backend — GET /api/ramais/ já existia e já retornava exatamente os dados necessários. manage.py check limpo (confirmado por precaução, já que a mudança foi só front-end).
31. Ramais vira uma seção com 5 subtelas (abas)
Dois prints do sistema antigo mostraram que "Ramais" no fundo é uma seção com 5 subtelas navegáveis por abas: Ramais, Responsável no Tareffa, Telefones Externos, Férias, Funções de Telefonia. Pedido: adicionar essa navegação em ramais.html, construir Telefones Externos e Funções de Telefonia de verdade, e criar Responsável no Tareffa/Férias como abas vazias ("sem informações, pois serão trabalhadas posteriormente"). Isso supera a decisão da rodada 21 de que Férias estava fora de escopo — agora a aba existe (vazia); a limitação de fundo (integração futura com outro banco) continua valendo, só a navegação foi antecipada.
Perguntado antes de implementar se "Funções de Telefonia" (tabela de comandos tipo *01 + Código de Agente → LogOn) deveria ser conteúdo fixo no HTML ou uma lista administrável no banco — o usuário escolheu lista administrável (CRUD completo), mesmo padrão de Telefones Externos.
O que foi construído:
- Dois models novos, ambos sem FK pra
Usuario(dados avulsos, mesmo espírito deRamalavulso):TelefoneExterno(nome/ramal/telefone/observações, sónomeobrigatório) eFuncaoTelefonia(comando/função/resumo, sócomandoobrigatório).FuncaoTelefonia.Meta.ordering = ["comando"]reproduz sozinho a ordem do print (*0, *01, *02, *03, *1, *2, *20, *21, *22, *23, *5, *503, *8) porque essa é exatamente a ordem lexicográfica da string — dispensou um campoordemmanual e endpoint de reorder. - Permissão reaproveitada: as duas subtelas usam o mesmo par
ramais.apps.visualizar/ramais.apps.editarque já existia (PermissaoApp("ramais", app_key)) — são subtelas da mesma seção do menu, não aplicações novas; nenhuma mudança emcatalogo.py. - Seed só para Funções de Telefonia:
seed_portal.pyganhouFUNCOES_TELEFONIA_SEED(as 13 linhas do print, viaupdate_or_createporcomando, idempotente) — decisão de que são comandos padrão de central telefônica (documentação genérica), diferente de Telefones Externos, que começa vazio de propósito (são contatos reais de fornecedores/terceiros, o usuário cadastra pela própria tela). - Abas: reaproveitado o CSS genérico
.pa-tabs/.pa-tab/.pa-tab-panelque já existia emperfis-acesso.css(mesmo padrão das abas Permissões/Usuários deperfis-acesso.html), com uma implementação independente emramais.js(data-ram-tab/data-ram-tab-panel/activeRamTab) pra não colidir comprofiles.js. O conteúdo que já existia (filtros/tabela/botões de Ramais) migrou pro paineldata-ram-tab-panel="ramais", sem mudar de comportamento. - Endpoints novos:
/api/telefones-externos/e/api/funcoes-telefonia/, CRUD padrão viaModelViewSet.
Migração 0014_funcaotelefonia_telefoneexterno gerada e aplicada no ambiente local; seed_portal reexecutado (confirmado via shell: 13 linhas de Funções de Telefonia, na ordem certa). manage.py check limpo. Ainda não testado num navegador de verdade — cadastrar/editar/excluir um Telefone Externo e uma Função de Telefonia, alternar entre as 5 abas, e conferir que um perfil só-visualizar não vê nenhum botão de escrita fica para a próxima validação.
32. Ramais: permissão granular por subtela (visualizar por aba + editar nas 3 administráveis)
No mesmo dia da rodada 31, o usuário pediu um ajuste no modelo de permissão recém-criado: em vez de um único par visualizar/editar cobrindo as 5 abas de Ramais, cada aba deveria ter sua própria permissão de visualização, e só as 3 com conteúdo administrável (Ramais, Telefones Externos, Funções de Telefonia) deveriam ter também uma permissão de edição própria — Responsável no Tareffa/Férias, sendo placeholders vazios, só precisam de visualizar. Pedido explícito de "estado inicial": só "Integração e Inovação" com acesso de editar nas 3, os demais 7 perfis com visualizar liberado em todas as 5.
Implementado reestruturando catalogo.MODULE_APPS["ramais"] de uma lista flat de 2 apps pra 5 subgrupos (mesmo formato {"key", "label", "tools": [...]} já usado em Auditorias) — cada subgrupo é uma aba, com 1 tool (Visualizar) ou 2 (Visualizar/Editar). Isso reaproveitou 100% a árvore de permissões genérica de profiles.js (renderTree/renderEntry/renderLeaf já sabem renderizar subgrupos com tools, usado desde a rodada de Auditorias) — nenhum código de UI novo em Perfis de Acesso.
O que mudou:
- Backend: os 4
ModelViewSetrelacionados (RamalViewSet,RamalAusenciaViewSet,TelefoneExternoViewSet,FuncaoTelefoniaViewSet) passaram a checar a chave específica da própria subtela ("telefones-externos-visualizar"/"editar", etc.) em vez do genérico"visualizar"/"editar"—RamalAusenciaViewSetusa as mesmas chaves deramais-visualizar/ramais-editardo diretório, por ser parte dessa mesma aba. seed_portal.py: o override que forçaeditar=Falsepros 7 perfis "normais" cresceu de 1 chave (ramais.apps.editar) pra 3 (ramais-editar,telefones-externos-editar,funcoes-telefonia-editar) — mesmo princípio de sempre, só mais chaves.ramais.js: reescrito o bloco de permissão — de um únicocanView/canManagepra 5 pares (um por aba), um objetoramTabViewPermsque esconde (hidden) o botão de cada aba semvisualizar, e a aba ativa por padrão passou a ser a primeira visível (não sempre "Ramais", que pode estar oculta pra um perfil específico no futuro, embora hoje todos os 7 perfis "normais" vejam as 5).ramais-lookup.js(modal de consulta rápida no topbar): trocado deapps.visualizargenérico praapps["ramais-visualizar"]especificamente, já que esse modal só mostra o diretório de Ramais.
Como isso mudou as chaves armazenadas em PerfilAcesso.permissoes["ramais"]["apps"] (não só valores), foi necessário reexecutar seed_portal pra sincronizar os 8 perfis com o novo formato — sem isso, todo perfil ficaria sem nenhum acesso a Ramais (as chaves antigas visualizar/editar não existem mais no catálogo). Confirmado via shell depois do reseed: os 7 perfis "normais" saíram com as 5 chaves *-visualizar=True e as 3 *-editar=False; só "Integração e Inovação" saiu com as 3 *-editar=True também. gabriel/bruno não foram tocados (proteção da rodada anterior, feedback_seed_nao_reseta_gabriel_bruno segue valendo). manage.py check limpo.
Nota pra quem for customizar um perfil manualmente na tela de Perfis de Acesso: qualquer ajuste fino que já tivesse sido feito nas chaves antigas (ramais.visualizar/ramais.editar) precisa ser refeito nas novas chaves — é uma troca de namespace, não uma migração automática de valor (JSONField não tem esse mecanismo).
33. Liderança (gerente/coordenador)
Pedido: gerentes/coordenadores precisam enxergar a agenda de compromissos privados de quem lideram, sem precisar que cada liderado marque o compromisso como "todos"/"departamento". Implementado com Usuario.lideranca (booleano) + Usuario.liderados (M2M auto-referenciado, symmetrical=False — "A lidera B" não implica o contrário). Dois pontos gravam a mesma relação: a tela de Usuários (seção "Liderança" no formulário, só quem tem gerencia_permissoes) e um modal novo "Gerenciar Usuário" (substituiu o antigo botão direto "Alterar senha" no dropdown da conta), que permite ao próprio gerente se autogerenciar via PATCH /api/me/liderados/ sem precisar de acesso à tela administrativa. CompromissoAgendaViewSet.get_queryset passou a incluir compromissos "somente_eu" de quem está em usuario.liderados, mas sem dar direito de editar (sou_dono continua False pra esses). O checklist de liderados (com busca por nome/departamento e "marcar todos os resultados da busca") virou um componente genérico (components.css) reaproveitado nos dois lugares. Migração 0015_usuario_liderados_usuario_lideranca. Detalhe completo em CLAUDE.md → "Liderança (gerente/coordenador) e o modal 'Gerenciar Usuário'".
34. Acessos Gerais — segunda aplicação de Links & Ferramentas
Pedido, com uma tela do Asana como referência: um cadastro de acessos/logins compartilhados da equipe (ex.: login geral de um site), organizado em seções e linhas, com popup de detalhes por linha. Virou a segunda aplicação real da seção "Links & Ferramentas" — o item do menu, que antes era um link direto, passou a ser um nav-group expansível com dois sub-itens ("Links & Ferramentas" e "Acessos Gerais"), cada um favoritável separadamente.
O que foi construído: dois models novos (AcessoGeralSecao, AcessoGeral, sem relação com LinkFerramenta), mesmo padrão visualizar/editar já usado em Links & Ferramentas (chaves próprias acessos-gerais-visualizar/acessos-gerais-editar); ordenação escopada por seção (não global); restrição opcional de seção por perfil (perfis_restritos M2M pra PerfilAcesso — filtro de dado, independente da árvore de permissões); e um editor de "Observações" com texto rico + imagens embutidas (contenteditable, colar/arrastar imagem vira data: URI, sem upload separado). Sanitização no backend via nh3 (não bleach, sem manutenção desde 2023) — allowlist estrita de tags/atributos, permitindo só <img> com esquema data: além de tags de texto básicas, contra XSS via HTML malicioso injetado no payload. Migrações 0016_acessogeralsecao_acessogeral e 0017_acessogeralsecao_perfis_restritos_and_more. Detalhe completo em CLAUDE.md → "Acessos Gerais".
35. Eventos Corporativos no Calendário Individual
Pedido: distinguir compromissos pessoais/departamentais de eventos corporativos formais (reuniões, treinamentos) — com categoria, local, modalidade (presencial/remoto/híbrido) e descrição — e restringir quem pode criar compromissos visíveis para o departamento ou para todos, que até então qualquer usuário podia fazer livremente.
O que foi construído: CategoriaEvento (nome único + cor hex, cadastro editável direto no modal de criar/editar compromisso, botão "+" ao lado do <select> de categoria); CompromissoAgenda ganhou categoria (FK), eh_evento (booleano — força visibilidade="todos" na validação), local, modalidade e descricao. Nova permissão calendario-individual-criar-evento (catalogo.py, único tool dentro de um subgrupo — não um par visualizar/editar, já que o módulo em si é liberado a todo perfil) passou a ser exigida por CompromissoAgendaSerializer.validate() para qualquer compromisso com visibilidade em departamento/todos — liberada só pra "Integração e Inovação" no seed_portal.py por ora, mesmo cuidado de sempre (calendario-individual está em BASE_KEYS). No calendário, eventos ganharam uma 5ª categoria de filtro/cor ("evento", token --coral), distinta das 4 já existentes (somente_eu/departamento/todos/equipe). Migrações 0018_remove_compromissoagenda_compartilhado_com_perfil_and_more, 0019_categoriaevento_compromissoagenda_descricao_and_more e 0020_compromissoagenda_eh_evento. Detalhe completo em CLAUDE.md → "Eventos Corporativos" (dentro de "Calendário Individual e Widgets").
36. Importação de Plano de Saúde (Utilitários)
Primeira aplicação de "Utilitários" (os placeholders "Conversor de Arquivos"/"Calculadora Fiscal" foram removidos do menu nesta mesma rodada). 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 do Questor, mais um relatório de auditoria do que não pôde ser lançado automaticamente. A lógica de negócio veio de um pipeline já testado (projects/importacao-planos-saude.skill + protótipo em projects/project/), portado quase 1:1 pra dentro do Django em portal_api/planos_saude/ (pacote Python puro, sem ORM).
Decisões principais: permissão de toggle único (quem tem acesso faz o fluxo inteiro — criar, revisar, gerar); histórico completo (cada importação fica salva, diferente de um fluxo descartável); regra de custeio (empresa/empregado/específica, com limite de valor e/ou percentual) configurável por tipo de lançamento e tipo de beneficiário na tela, em vez da regra fixa do pipeline original; resolução manual de auditoria por nome (confirmação humana item por item quando o casamento automático falha, nunca fuzzy matching); pré-validação de cada arquivo anexado antes do envio final, reaproveitando o mesmo parser Python que o create() usaria. Migrações 0021_importacaoplanosaude_importacaoplanosaudeauditoria_and_more e 0022_importacaoplanosaudeauditoria_resolucao_manual. Detalhe completo em CLAUDE.md → "Importação de Plano de Saúde (Utilitários)".
37. Simulação de Custo de Contratação (Geradoc)
Pedido (2026-08-11): substituir a planilha manual de custo de contratação (projects/planilha de custo/*.xlsx) por um formulário no Portal que gera um PDF pronto pra enviar ao cliente — primeira aplicação de "Geradoc" a sair do estado de placeholder.
Decisões confirmadas com o usuário: v1 cobre só Empregado CLT (as outras 4 modalidades do pedido original — Simples Nacional, Regime Normal, Pró-labore, Empregado Doméstico — ficam para quando houver planilha de referência equivalente); cálculo pontual, sem persistência (devolve o PDF direto, nada salva no banco); corrigido um gap real da planilha original (dedução por dependente não somava à base do IRRF quando usava desconto real de INSS); e as tabelas de INSS/IRRF, que nasceram hardcoded, viraram um cadastro editável (ParametroFiscalCustoContratacao, singleton) na mesma rodada em que a redução de IRRF da Lei 15.270/2025 (vigente desde jan/2026) foi implementada, já que ambas mudam por lei/todo ano. PDF gerado via reportlab (adicionado ao requirements.txt), com cabeçalho nas cores reais da marca (dourado/marrom amostrados de logo.png), não o roxo do tema do Portal. Migração 0023_parametrofiscalcustocontratacao. Detalhe completo em CLAUDE.md → "Simulação de Custo de Contratação (Geradoc)".
38. Indicador de Desempenho (Geradoc)
Pedido (2026-08-12): substituir a apuração manual do indicador de desempenho do Fiscontábil — feita numa planilha (projects/Indicadores/FISCO CONTABIL *.ods) com fórmulas quebradas por edições manuais acumuladas — por uma ferramenta completa dentro de Geradoc, com histórico de apurações mensais e recibo em PDF por colaborador. A maior feature construída até aqui em número de models/endpoints (6 models, 6 ModelViewSet, pacote de negócio próprio portal_api/indicadores/ com 6 arquivos).
Escopo confirmado com o usuário: v1 cobre só o Fiscontábil (papéis Balancete/Liberação Fiscal/Conciliação); o "tipo" do colaborador é derivado por empresa via a planilha "Serviços Tareffa", não é cadastro; só 3 critérios (entrega de balancetes/liberações fiscais/conciliações no prazo) são calculados automaticamente, todo o resto é marcação manual do RH; critérios e percentuais por tipo são cadastros genéricos editáveis pela tela, não hardcoded (percentuais nunca editados in-place, só um histórico com vigente_desde); ajuste manual em dois níveis (por critério, e pelo percentual agregado Individual/Grupo/Departamento, este último aplicado de uma vez a todo o grupo/departamento — "cada gerente representa um grupo"); recibo é documento interno do RH, sem visão do próprio colaborador nesta v1. Validado com dados reais de 43 colaboradores, o que revelou e corrigiu dois bugs de robustez: openpyxl em modo read_only precisa de .close() explícito no Windows (senão bloqueia excluir o upload depois) e campos percentuais precisaram de max_digits=7 (não 6) pra não estourar em cálculos que batem exatamente 100%. Primeiro histórico populado via seed_indicador_desempenho (idempotente), com os valores exatos da planilha antiga. Migrações 0024 a 0028. Detalhe completo em CLAUDE.md → "Indicador de Desempenho (Geradoc)".
Ajuste pequeno feito logo depois de testar a tela de revisão no navegador: a coluna "Meta (%)" da tabela "Metas de Grupo e Departamento" era um campo de texto livre (permitindo qualquer percentual) — o usuário apontou que, pra Grupo/Departamento, só existem duas possibilidades reais ("será pago ou não"), então o campo virou um <select> Sim/Não (100%/0%), reaproveitando o mesmo raciocínio já aplicado aos critérios individuais de Grupo/Departamento (que também só têm Sim/Não, sem "não se aplica"/"não faz"). O percentual Individual de cada colaborador (composição ponderada dos 3 níveis) continua livre, por poder ser legitimamente fracionário.
Segundo ajuste, também depois de testar no navegador: o resumo do card de cada colaborador só mostrava "Individual: X%", sem explicar como esse número foi composto. Pedido: mostrar a composição completa (ex.: "Individual: 57,14% (peso 60%) Grupo: 100% (peso 10%) Departamento: 100% (peso 30%) Total Indicador: 74,29%"), com cada um dos 3 níveis em verde/vermelho conforme foi atingido (100%) ou não; o Total em si fica neutro (sem cor), pra não repetir a mesma informação 4 vezes. O percentual bruto de Individual (antes da composição) e o peso de cada nível eram calculados em calculo.recalcula_colaborador mas descartados depois de usados — extraída a lógica de filtro de respostas por nível pra uma função reaproveitável (respostas_aplicaveis) e adicionada composicao_individual(), exposta como campo computado (composicao_individual) em IndicadorApuracaoColaboradorSerializer, sem nenhuma migração (não persiste nada novo, só reconstrói pra exibição). Aproveitado pra adicionar prefetch_related na action retrieve de IndicadorApuracaoViewSet (colaboradores__empresas, colaboradores__respostas__criterio), já que o campo novo faria mais uma consulta por colaborador na tela de revisão sem isso. Layout do cabeçalho do card também foi reorganizado a pedido do usuário: a composição saiu de baixo do nome/gerente pra ficar ao lado, centralizada, numa coluna própria do grid; e o lápis de ajuste manual do Total passou a ficar sempre ao lado do valor (isolado numa linha própria que nunca quebra), não mais embaixo quando o rótulo "Total Indicador" (mais longo que o antigo "Individual") não coubesse na coluna.
Terceiro ajuste: a tabela de Metas de Grupo/Departamento tinha um <select> Sim/Não duplicado por critério — um ao lado do texto que descreve o critério (bulk, via aplicar-em-lote) e outro na coluna "Meta (%)" à direita (que já ajusta pct_grupo/pct_departamento direto). O usuário pediu pra remover o primeiro, mantendo só o da direita — renderMetaCriteriosHtml voltou a ser só texto informativo (nome + peso do critério), e o listener de change associado a .ind-meta-criterio-select foi removido. Responder um critério específico continua possível por colaborador, dentro do card de revisão (renderRespostasGrupoHtml) — só o atalho de responder em lote pela tabela de metas deixou de existir.
Quarto ajuste: o modal "Ajuste Indicador em Lote" (ajusta pct_individual de vários colaboradores selecionados de uma vez) tinha um campo de texto livre "Percentual Individual (0 a 100)". Mesmo raciocínio das rodadas anteriores — o RH só usa esse ajuste em lote pra dois casos reais ("considerar atingido, mesmo quem não bateu a meta" ou "desfazer o ajuste manual") — trocado por dois botões, "Sim" (aplica pct_individual=100 a todos os selecionados) e "Reverter" (chama a action recalcular de cada colaborador selecionado, voltando ao cálculo automático). Nenhuma mudança de backend — os dois endpoints por-colaborador já existiam (PATCH e recalcular), só o disparo em paralelo (Promise.all) mudou de "um valor pra todos" pra "uma ação pra todos". Ajustado de novo logo em seguida: "Reverter" e "Sim" (renomeado pra "Ajustar") viraram os dois btn-solid, mesma cor — só "Cancelar" ficou btn-outline — já que as duas ações são igualmente "reais", não uma primária/secundária.
Quinto ajuste: o checkbox "Só com honorário não encontrado" (filtrava a lista de colaboradores pra só quem tinha alguma empresa sem honorário) virou um botão dedicado, "Visualizar Empresas sem Honorário" (com contador), que abre um modal próprio. Pedido explícito do usuário: 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) — preencher o honorário uma vez deve valer pra todos eles de uma vez, já que o honorário é da empresa, não da pessoa. Isso não era possível antes: o campo de preencher honorário existente (PATCH /api/indicadores-apuracoes-empresas/{id}/) só ajustava uma linha por id.
O que foi construído: o modal agrupa as IndicadorApuracaoEmpresa com honorario_nao_encontrado=True da apuração por codigo_empresa (mostrando os colaboradores/tipos responsáveis por cada uma), com um campo de honorário por grupo. Novo endpoint POST /api/indicadores-apuracoes/{id}/ajustar-honorario-empresa/ (IndicadorApuracaoViewSet.ajustar_honorario_empresa, serializer IndicadorApuracaoAjusteHonorarioEmpresaSerializer) atualiza todas as linhas com aquele código na apuração de uma vez, recalculando cada colaborador afetado — mesmo padrão de ajustar_grupo/ajustar_departamento (aplicar uma mudança a um escopo de uma vez, não registro a registro). O endpoint por-linha antigo continua existindo, usado pela tabela "Empresas" de dentro do card do colaborador (caso raro de querer corrigir só uma linha). Nenhuma migração — só um endpoint novo, sem campo novo no model.
Bug corrigido logo depois de testar: numa apuração com muitas empresas sem honorário, o modal crescia além da altura da tela (mesma causa raiz já documentada em CLAUDE.md pra .calendar-day — um filho de flex-column só rola em vez de esticar o pai quando o próprio pai também tem uma altura limitada e o filho tem min-height:0). Corrigido dando max-height:85vh a .ind-esh-modal-card e overflow-y:auto/min-height:0 à lista (.ind-esh-list) — título, aviso e os botões de ação ficam sempre visíveis, só a lista de empresas rola internamente quando não cabe.
Sexto ajuste, três pedidos numa rodada só: (1) ao preencher o honorário (linha única ou em lote pelo modal novo), deixar uma nota "honorário ajustado manualmente" na tabela "Empresas" de dentro do card do colaborador — campo novo IndicadorApuracaoEmpresa.honorario_ajustado_manualmente (migração 0029, junto com a mudança do item 2), marcado pelos dois caminhos de ajuste (linha única e em lote) e exposto no serializer; sem UI de reverter, já que não existe "automático" pra essa linha voltar (o código nunca casou com a planilha). (2) Listar as empresas por código, não por nome, na mesma tabela — trocado IndicadorApuracaoEmpresa.Meta.ordering de ["nome_empresa", "id"] pra ["codigo_empresa", "id"] (mesma migração 0029). Bug reportado logo depois de testar: "80" e "503" apareciam no fim da lista, depois de "2134" — codigo_empresa é CharField, então ordenar só por ele é alfabético ('8'/'5' são "maiores" que '1'/'2' como caractere, mesmo o número sendo menor), não numérico. Corrigido (migração 0030) ordenando primeiro por Length("codigo_empresa") e só depois pelo valor — pra códigos sem zero à esquerda, string mais curta é sempre número menor, então isso reproduz a ordem numérica certa sem precisar converter pra inteiro (que quebraria com erro de banco se algum código não fosse só dígitos). (3) Botão "Visualizar Empresas sem Honorário" ganhou cor de atenção (--danger, mesma linguagem visual do input/selo de honorário não encontrado) — não reaproveitado .btn-danger-outline (que tem margin-right:auto, pensado pra separar um botão "Excluir" dentro de .modal-actions, efeito colateral indesejado no toolbar) — classe própria .ind-empresas-sem-honorario-btn só com as cores.
Sétimo ajuste, testando o modal "Empresas sem Honorário" com dados reais: três pedidos. (1) Ordenar também por código nessa lista (estava só por nome) — reaproveitado o mesmo critério "tamanho da string primeiro" da correção anterior, agora também em JS (empresasAgrupadasPorCodigo), já que essa lista é montada em memória a partir do que já foi carregado, não vem de uma query com Meta.ordering. (2) Código antes do nome no cabeçalho de cada item — trocada a ordem dos dois <span> (.ind-esh-codigo primeiro), mesma ordem da tabela "Empresas" do colaborador (coluna "Código" antes de "Empresa"). (3) Um segundo botão/modal, "Verificar Empresas Ajustadas Manualmente", pra rever e corrigir um honorário já ajustado — antes só dava pra preencher uma vez (o modal "sem honorário" some da lista assim que honorario_nao_encontrado vira falso, sem nenhum caminho de volta pra editar de novo). Backend: o filtro de ajustar_honorario_empresa mudou de honorario_nao_encontrado=True pra Q(honorario_nao_encontrado=True) | Q(honorario_ajustado_manualmente=True) — o mesmo endpoint agora cobre preenchimento inicial e correção, sem endpoint novo. Frontend: as funções de agrupar/renderizar/salvar dos dois modais foram generalizadas (parametrizadas por um filtro e pelos ids de cada um) em vez de duplicadas; o modal de correção pré-preenche o campo com o valor atual (o de preenchimento inicial continua em branco).
Ajustado de novo na sequência, testando o segundo botão/modal recém-criado: o usuário pediu pra não ter um botão separado — "facilitando a usabilidade da ferramenta". Revertido pra um popup só: o botão/modal "Verificar Empresas Ajustadas Manualmente" foi removido, e sua lista virou uma segunda seção dentro do próprio popup "Empresas sem Honorário" (.ind-esh-section-title como divisor), embaixo da lista original. As duas listas passaram a viver num wrapper único que rola (.ind-esh-scroll), com o título e o botão "Fechar" sempre visíveis fora dele — antes cada modal tinha sua própria rolagem. renderEmpresasHonorario() (nova função) renderiza as duas listas de uma vez, tanto ao abrir o popup quanto depois de qualquer "Salvar" — necessário porque corrigir uma empresa "sem honorário" faz ela migrar pra seção "ajustada manualmente" na hora, então as duas sempre precisam refletir o estado atual juntas. Nenhuma mudança de backend nesta correção.
Ajustado uma terceira vez, testando a versão com as duas seções sempre visíveis: pedido pra a seção "ajustadas manualmente" ficar escondida por padrão, atrás de um botão no final do modal — "lá seja possível a correção" quando o usuário quiser ver. Adicionado #ind-empresas-ajustadas-toggle-btn (largura cheia, com contador, alterna "Visualizar"/"Ocultar Empresas Ajustadas Manualmente (N)") logo depois da lista principal, escondendo #ind-empresas-ajustadas-section por padrão (hidden, resetada a cada abertura do popup) e só renderizando/mostrando a lista quando o botão é clicado. renderEmpresasHonorario() ajustada pra só re-renderizar a seção "ajustadas" se ela já estiver aberta — evita trabalho à toa quando ela está escondida, mas mantém sincronizada se o usuário já estiver com ela visível ao salvar algo na lista principal. Nenhuma mudança de backend.
39. Indicador de Desempenho: checklist de validação por colaborador
Pedido: um checkbox no início de cada card de colaborador (tela de revisão), pra o RH marcar quem já validou — ao marcar, a borda do card fica verde, pra dar visibilidade de quem ainda está pendente numa apuração com muitos colaboradores.
O que foi construído: campo novo IndicadorApuracaoColaborador.validado (migração 0031) — booleano simples, sem relação com nenhum cálculo (nem participa de calculo.recalcula_colaborador). Novo endpoint POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/ só grava esse campo. Diferente de todos os outros ajustes desta tela (que recarregam a apuração inteira e re-renderizam tudo depois de qualquer mudança), marcar/desmarcar o checklist atualiza só o card clicado no DOM, sem recarregar nem re-renderizar a lista inteira — decisão deliberada, já que essa ação tende a ser repetida muitas vezes seguidas numa conferência longa, e um refresh completo fecharia outros cards já expandidos e resetaria a posição de rolagem a cada clique. Erro de rede reverte o checkbox e o estado local, mesmo padrão de outros toggles imediatos do app.
Detalhe de acessibilidade descoberto ao implementar: o gate de clique que expande/recolhe o card no cabeçalho precisou excluir o <label> inteiro do checkbox, não só o <input> — clicar na área do label fora do glifo do checkbox dispara dois eventos de clique encadeados (um no label, outro sintético no input), e só excluir o input pelo seletor deixava o primeiro clique (target=label) passar batido, expandindo/recolhendo o card ao mesmo tempo que marcava/desmarcava o validado.
40. Indicador de Desempenho: corrigir o responsável por uma empresa/papel
Pedido, com exemplo concreto: a empresa 168 (MULTIVERSA CONSULTORIA LTDA) tinha "Valéria Bonete — Fiscal" como responsável, mas devia ser "Alan Lima Cassulli — Fiscal". Ou seja, reatribuir qual colaborador responde por um papel (tipo) de uma empresa — diferente de tudo que já existia na tela (honorário, percentuais), que nunca mexia em quem é o responsável, só em valores.
O que foi construído: novo botão "Corrigir Responsável" (popup próprio, busca por nome/código entre todas as empresas da apuração — não só as com honorário pendente) — selecionar uma empresa mostra a mesma lista de responsáveis já usada no popup "Empresas sem Honorário" ("Nome — Tipo"), mas agora com um <select> por linha pra escolher outro colaborador da apuração. Novo endpoint POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/ (IndicadorApuracaoEmpresaViewSet.trocar_responsavel) só troca a FK colaborador da linha (codigo_empresa/tipo/honorário continuam intactos) e recalcula os dois colaboradores envolvidos — o que perdeu a empresa e o que ganhou, já que o conjunto de empresas de cada um mudou. Validações no backend: o novo colaborador precisa existir na mesma apuração, não pode ser o mesmo de já é, e não pode já ser responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas). O <select> de cada linha já exclui o colaborador atual das opções e nasce num placeholder desabilitado, pra nunca reatribuir sem escolha explícita do usuário. Nenhuma migração — só um endpoint novo, reaproveitando o model existente.
Ajuste pequeno na sequência, no botão "Visualizar Empresas sem Honorário" (popup "Empresas sem Honorário", rodada anterior): ele continuava vermelho e com o mesmo texto mesmo quando não havia mais nenhuma empresa pendente. Pedido: nesse caso, o botão devia virar "Visualizar Empresas com Honorário Ajustado" e perder a cor de atenção, já que não há mais nada de errado pra resolver. atualizarBotaoEmpresasSemHonorario() (JS) passou a alternar a classe .ind-empresas-sem-honorario-btn (antes fixa no HTML, agora só aplicada via JS quando semHonorario > 0) e o texto do botão conforme o total de empresas pendentes.
41. Indicador de Desempenho: filtro por setor organizacional, com meta de Departamento própria por setor
Pedido, olhando a tabela "Metas de Grupo e Departamento" real (competência 2026-07, 43 colaboradores): trocar o filtro por gerente por botões de departamento/setor no topo da revisão — selecionar um setor (ex.: "Fisco/Contábil") devia restringir a tabela de Metas a só a linha de Departamento daquele setor + os gerentes dele, e a lista de colaboradores abaixo a só quem tem esses gerentes. O pedido também trazia uma regra de negócio concreta: "Contabilidade" e "Fiscal / Tributário" formam um setor só ("Fisco/Contábil"); os demais setores são independentes; os próprios líderes do Fisco/Contábil (João Candido Rodrigues, Lhais Vergilio Delavy, Paloma Ramão, Elizangela dos Santos) deviam aparecer num setor à parte, "Gerentes"; e Luciane Gonzaga no setor "Rocket".
Antes de implementar, esclarecido com o usuário (a mudança tinha implicações que iam além de "só um filtro visual", ver perguntas feitas): (1) cada setor passaria a ter sua própria meta de Departamento (pct_departamento), não mais um valor único pra toda a apuração — escolhido em vez de manter uma meta global só filtrada na tela; (2) a associação pessoa→setor devia ser um cadastro editável, não fixo no código — mas o usuário revelou, na sequência, que o setor de cada colaborador já existe como coluna ("departamento") na planilha Serviços Tareffa, então não precisava de um cadastro manual pra maioria dos casos.
Investigado o arquivo real (projects/Indicadores/Serviços Tareffa.xlsx) pra confirmar: a coluna "departamento" tem valores "Contabilidade"/"Fiscal / Tributário"/"Rocket"/"Condomínio"/"Pessoa Física IRPF"/"Auditoria Fisco/Contábil" por linha de serviço. Rodando a extração real dos 43 colaboradores da apuração existente, confirmou-se que os 4 líderes citados pelo usuário têm o setor bruto da planilha igual ao setor operacional de quem lideram (ex.: João Candido Rodrigues aparece como "Contabilidade", não "Gerentes") — ou seja, a fusão automática Contabilidade/Fiscal→Fisco/Contábil não bastava pra colocá-los em "Gerentes"; só Luciane Gonzaga já vinha certa ("Rocket") sem precisar de nada extra.
O que foi construído (ver CLAUDE.md → "Setor organizacional" pro detalhe técnico completo):
- Campo novo
IndicadorApuracaoColaborador.setor(migração0032) — valor bruto da coluna "departamento" da planilha, lido emleiaute.le_servicos_tareffa(campo novoLinhaTareffa.setor) e propagado porpipeline.processa_apuracaoaté o colaborador (primeira ocorrência por responsável, mesmo padrão já usado pragerente). - Model novo
IndicadorSetorApelido(nome do colaborador → setor) — cadastro persistente e editável (aba "Apelidos de Setor" em Configurações,/api/indicadores-setores-apelidos/, CRUD completo) pros casos em que o setor bruto da planilha não reflete o setor "de verdade" da pessoa (os 4 líderes). Cadastrados no ambiente local: os 4 nomes → "Gerentes". portal_api/indicadores/setores.py(novo módulo, sem ORM direto além de lerIndicadorSetorApelido):resolve_setor(nome, setor_bruto, apelidos)aplica o apelido se existir, senão funde "Contabilidade"/"Fiscal / Tributário" em "Fisco/Contábil" (FUSAO_SETORES) e usa o valor bruto como está pros demais (Rocket, Condomínio, Pessoa Física IRPF, ...) — nenhum código novo é necessário quando um setor novo aparecer na planilha.pct_departamentodeixou de ser ajustado/recalculado pra toda a apuração de uma vez —IndicadorApuracaoViewSet.ajustar_departamento/recalcular_departamentoagora recebemsetorno corpo e aplicam só aos colaboradores daquele setor (setores.colaboradores_do_setor), mesma mecânica queajustar_grupo/recalcular_grupojá usavam por gerente.- Frontend (
indicador-desempenho.js): removido o<select id="ind-filtro-gerente">; adicionados botões de setor (#ind-filtro-setor,.ind-setor-chip) no topo da seção de Metas — clicar num setor filtra tanto a tabela de Metas (uma linha "Departamento (todo o <setor>)" + as linhas de Grupo só dos gerentes daquele setor) quanto a lista de colaboradores abaixo. Camposetor_bucket(SerializerMethodField, já resolvido no servidor) exposto por colaborador. - Apurações criadas antes desta mudança ficam com
setorem branco — feito um backfill pontual, viamanage.py shell, lendo de novo a planilha Tareffa já anexada à única apuração existente no ambiente (competência 2026-07); não existe management command dedicado pra isso ainda, caso surja uma apuração antiga sem o arquivo disponível.
Validado contra os dados reais da apuração existente antes de considerar pronto: sem apelido, o agrupamento automático já dava Fisco/Contábil=35 (incluindo os 4 líderes) + Condomínio=4 + Pessoa Física IRPF=1 + Rocket=2 + Auditoria Fisco/Contábil=1; com os 4 apelidos cadastrados, o resultado bateu exatamente com o pedido: Fisco/Contábil=32 (gerentes "João Candido Rodrigues"/"Lhais Vergilio Delavy"), Gerentes=4, Rocket=2, Condomínio=4, Pessoa Física IRPF=1.
42. Indicador de Desempenho: três ajustes rápidos de usabilidade na tela de revisão
Testando a rodada 41 no navegador, três pedidos pequenos em sequência:
-
Info no "Honorário Ajustado": pedido, com print, de um botão de informação ao lado do cabeçalho da coluna "Honorário Ajustado" (tabela "Empresas" de dentro do card do colaborador) — ao clicar, abre um popup pequeno explicando que o valor é o honorário proporcionalizado ao percentual do Indicador atingido, já mostrando o percentual real daquele colaborador (não um texto genérico). Não existia nenhum componente de popover/tooltip no projeto — construído do zero (
.ind-info-wrap/.ind-info-btn/.ind-info-popoveremindicador-desempenho.css), inspirado no mesmo mecanismo de.notif-dropdown(notifications.js): botão "i" (data-ind-info-toggle) alterna um<span>posicionado emabsolutelogo abaixo, e um listener emdocumentfecha ao clicar fora. Como o botão fica dentro de um<th>(que temtext-transform: uppercase/fonte pequena via.pa-table th), o popover precisou resetar essas propriedades pra virar texto normal de novo. Todo o texto (incluindo o percentual) é montado emrenderHonorarioAjustadoInfoHtml(colaborador), chamada por colaborador ao montar a tabela — sem mudança de backend,pct_individualjá vinha no payload. -
Filtro por setor no "Ajuste Indicador em Lote": o modal já tinha busca por nome; pedido pra também poder filtrar por departamento/setor. Adicionado um
<select id="ind-lote-global-setor">(opções = setores distintos da apuração, viapidIndAgruparPorSetor) que combina (E lógico) com a busca por nome — os dois filtros juntos decidem quem aparece no checklist e quem "Marcar todos os resultados da busca" marca. Nenhuma mudança de backend (filtragem 100% em memória sobrecolaborador.setor_bucket, já exposto desde a rodada 41). -
Modal de seleção pra "Gerar Recibos": antes, o botão gerava na hora pra todos os colaboradores da apuração, sem escolha. Pedido: abrir um modal parecido com o de "Ajuste Indicador em Lote" — mesma busca por nome + filtro por setor + checklist com "marcar todos os resultados da busca" — permitindo gerar recibo de um colaborador só, de alguns específicos, de um setor inteiro, ou de todo mundo. Implementado reaproveitando exatamente o mesmo padrão de UI do item 2 (funções/variáveis próprias, prefixo
gerarRecibos*, sem duplicarloteGlobal*), com uma diferença deliberada: o checklist já nasce com todo mundo marcado ao abrir (reproduz o comportamento antigo — gerar pra todos — sem exigir que o RH marque um por um; ele só desmarca quem não quer incluir desta vez).Mudança de backend:
IndicadorApuracaoViewSet.gerarpassou a aceitarcolaborador_ids(lista, opcional) no corpo — sem isso, gera pra todos (comportamento antigo); com isso, gera só pra quem foi pedido, validando que todos os ids pertencem à apuração (400 caso contrário). Decisão tomada sem perguntar, por ser a leitura mais correta do dado: a apuração só é marcadaconcluidaquando a seleção pedida cobre todos os colaboradores (sem filtro, ou uma seleção que bate com o total) — gerar um recibo avulso pra conferência não deveria fechar a apuração inteira como se o mês tivesse sido todo revisado. Validado viadjango.test.Clientlogado comogabriel: geração parcial (1 colaborador) mantevestatus="revisao"; geração de todos marcouconcluida; um id de outra apuração devolveu 400. O estado real da apuração no ambiente local (já estavaconcluidade um uso anterior) foi restaurado ao original depois do teste.
43. Indicador de Desempenho: três ajustes no PDF do recibo
Pedido com print real do PDF gerado (colaborador com o Individual ajustado manualmente pra 100%, e colaborador Natan da Costa com a tabela de Empresas ilegível):
-
Banner "PERCENTUAL DO INDICADOR INDIVIDUAL" mostrava o valor pago, não o medido: quando o RH/Diretoria ajusta o percentual Individual manualmente (
pct_individual_ajustado_manualmente=True),colaborador.pct_individualpassa a guardar só o valor sobrescrito — o percentual real medido (a composição dos 3 níveis) não ficava salvo em lugar nenhum depois do ajuste. Pedido: o banner deve sempre mostrar o valor efetivo/medido (ex.: 74,29%), independente do ajuste; embaixo, a linha que dizia "Percentual individual ajustado manualmente pelo RH" devia virar "Percentual Individual Ajustado Pela Direção" com o percentual ajustado ao lado (ex.: 100%) — os dois números lado a lado, pra ficar claro o que foi atingido e o que foi considerado.Implementado sem duplicar a fórmula:
calculo.composicao_individual()(já calculava os 3 níveis brutos pra exibição no card de revisão) ganhou uma chave nova,total_calculado— a mesma composição ponderada (_combina_niveis) querecalcula_colaboradorteria gravado empct_individualse não houvesse ajuste manual, calculada ali mesmo a partir dos níveis que a função já monta.recibo.py._banner_percentualpassou a mostrarcomposicao["total_calculado"]no banner principal (sempre o medido) e, só quandopct_individual_ajustado_manualmente, uma linha extra com o rótulo novo +colaborador.pct_individual(o valor pago). -
"Total Resultado" → "Total do Indicador": troca simples de rótulo na última linha da tabela "Empresas" (
_tabela_empresas). -
Overflow generalizado na tabela "Empresas" (causa raiz do "texto da coluna Tipo tapando o Hon. Ajustado" e dos valores da linha de total "ultrapassando as linhas"): toda célula da tabela, exceto "Empresa", era uma string solta, não um
Paragraph— string solta não quebra linha dentro da largura da coluna; combinado comALIGNà direita (aplicado a todas as colunas de valor, inclusive "Tipo" sem querer) ou ao negrito da linha de total (mais largo que a mesma string em peso normal), um valor mais largo que a coluna vazava visualmente por cima da célula vizinha em vez de quebrar linha — daí "Contador (com conciliador)" (right-aligned, mais largo que a coluna de Tipo) vazar pra esquerda cobrindo Hon. Ajustado, e os totais em negrito vazarem na última linha mesmo cabendo em peso normal. Corrigido convertendo toda célula emParagraphcom um estilo de alinhamento próprio (celula_centro/celula_direita/celula_negrito/celula_direita_negrito, novos em_estilos()) — agora qualquer valor mais largo que a coluna quebra em duas linhas em vez de vazar. Coluna "Tipo" também ganhou um dicionário de labels curtos só pro PDF (TIPO_LABEL_CURTO: "Contador SC"/"Contador CC" em vez de "Contador (sem/com conciliador)", abreviação que já era usada informalmente na documentação do projeto) — o label completo não cabia nem quebrando linha numa coluna estreita. Larguras de coluna também redistribuídas (Tipo de 1,5 pra 2,2cm, usando os ~1,3cm de folga que a tabela tinha sobre a largura útil da página A4).Validado gerando de verdade os PDFs de "Alan Lima Cassulli" (sem ajuste manual — banner mostra só o medido, sem linha extra) e "Natan da Costa" (com ajuste manual pra 100% — banner mostra 81,74% medido + linha "Percentual Individual Ajustado Pela Direção: 100,00%") a partir da apuração real do ambiente, lendo o PDF gerado de volta pra conferir visualmente — tabela "Empresas" sem nenhum vazamento em nenhum dos dois casos, "Contador CC"/"Contador SC" legíveis na coluna Tipo, "Total do Indicador" com os 4 valores certos sem sobrepor.
44. Indicador de Desempenho: recibo em PDF — "R$" separando do valor + capitalização do rótulo
Testando a rodada 43 com valores maiores (ex.: R$ 27.710,19), dois ajustes finos:
- "R$" quebrando pra uma linha acima do valor: ao converter as células da tabela "Empresas" pra
Paragraph(rodada 43), o espaço comum entre "R$" e o número virou um ponto de quebra de linha válido — quando o valor não cabia numa linha só, o reportlab quebrava bem ali, deixando "R$" sozinho acima do número em vez de vazar (o bug da rodada anterior), mas ainda longe do ideal. Pedido: "R$" deve estar sempre do lado esquerdo do valor, nunca acima. Trocado o espaço comum por (não separável) em_moeda()— sozinho isso só moveu o ponto de quebra pra dentro do próprio número (ex.: "R$ 27.710,1" / "9"), então a correção completa também exigiu abrir mais espaço de verdade pras colunas:LEFTPADDING/RIGHTPADDINGda tabela reduzidos de 6pt (padrão do reportlab) pra 3pt, e as larguras das colunas Honorário/Hon. Ajustado/Indiv./Grupo/Depto./Total redistribuídas (tirando um pouco de Código e Empresa, que tinham folga de sobra) pra caber o maior valor real visto na apuração (R$ 28.023,16) numa linha só, com padding. - Capitalização do rótulo: "Percentual Individual Ajustado Pela Direção" (Title Case) virou "Percentual individual ajustado pela direção" (só a primeira letra maiúscula).
Validado regerando os mesmos dois recibos da rodada 43 (Alan Lima Cassulli, Natan da Costa) — inclusive o valor mais alto da apuração inteira (R$ 28.023,16, CELLSHOP DUTY FREE no recibo de Natan da Costa) coube numa linha só, "R$" sempre grudado à esquerda do número, em toda a tabela e na linha de total.
45. Indicador de Desempenho: Departamento como entidade própria — critérios e percentuais por departamento
Pedido: "vamos passar a apurar as metas e regras por departamento" — critérios e percentuais deixam de ser globais (uma regra só pra toda a apuração) e passam a ser por departamento (ex.: a regra do Fisco/Contábil pode ser diferente da do Condomínio). Duas exigências explícitas: (1) precisa de um cadastro de verdade de departamentos, com relação a gerentes — um departamento pode ter mais de um gerente (ex.: Fisco/Contábil tem João Candido Rodrigues e Lhais Vergilio Delavy); por enquanto essa relação é mantida manualmente pela própria aplicação (alimentar da planilha fica pra decidir depois); (2) por enquanto replicar a mesma regra em todo departamento, mas a estrutura já precisa suportar customização.
Decisão tomada com o usuário antes de implementar (rodada consultada via pergunta direta, dado o tamanho da mudança): o novo cadastro de Departamento substitui por completo o mecanismo de "setor" da rodada 41 (coluna bruta da planilha + fusão automática + IndicadorSetorApelido) — fica só um conceito de departamento no sistema, usado tanto pras Metas quanto agora pra critérios/percentuais. O caso que o apelido resolvia (os 4 líderes de Fisco/Contábil) passa a ser coberto mapeando a gerente deles, "Elizangela de Paula Kuhn", pro departamento "Gerentes".
O que foi construído (ver CLAUDE.md → "Departamento organizacional" pro detalhe técnico completo):
- Dois models novos:
IndicadorDepartamento(nome/ativo) eIndicadorDepartamentoGerente(gerente→departamento,nome_gerenteúnico — um gerente só pertence a um departamento, mas um departamento aceita vários gerentes). IndicadorCriterio/IndicadorPercentualTipoganharam FK obrigatóriadepartamento— cada departamento passa a ter seu próprio histórico de critérios/percentuais, de verdade (não é mais um bucket calculado, é uma tabela filtrada por FK).IndicadorApuracaoColaborador.setor(texto) viroudepartamento(FK nullable) — resolvido uma vez na criação da apuração, a partir dogerentedo colaborador viaIndicadorDepartamentoGerente(não mais da coluna bruta da planilha). Sem mapeamento pro gerente, o colaborador fica comdepartamento=Nonee vira um aviso no processamento.pipeline.processa_apuracao()também passou a agrupar os critérios automáticos por departamento (criterios_automaticos_por_departamento) — cada colaborador só calcula os critérios do seu departamento, não mais todos os critérios ativos da apuração.- Migração em 3 passos (schema com FK nullable →
RunPythoncriando "Fisco/Contábil" e apontando todo critério/percentual já existente pra ele, já que era literalmente o que a regra única representava → schema tornando a FK obrigatória) — mesmo padrão de qualquer FK NOT NULL adicionada numa tabela já populada. seed_indicador_desempenho.pyajustado pra criar/reaproveitar o departamento "Fisco/Contábil" antes de popular critérios/percentuais (senão quebraria ao rodar de novo, já que os models agora exigem departamento) — testado rodando de novo: atualizou os 7 critérios e reconheceu os 5 percentuais já existentes, sem duplicar nada.- Tela de Configurações: aba "Apelidos de Setor" virou aba "Departamentos" (cadastro de departamentos + botão "Gerenciar Gerentes" por linha, popup com lista de gerentes daquele departamento + campo pra adicionar um novo). Abas "Critérios" e "Percentuais por Tipo" ganharam um filtro por departamento no topo + coluna "Departamento" na tabela + campo obrigatório de departamento no modal de adicionar. Filtro de Metas/"Ajuste Indicador em Lote"/"Gerar Recibos" (todos já existentes desde a rodada 41) tiveram a chave de agrupamento trocada de string (
setor_bucket) pra id (departamento), mesma mecânica visual.
Validado via django.test.Client (login gabriel): criados 4 departamentos de teste + 6 relações gerente→departamento reproduzindo a estrutura real conhecida (Fisco/Contábil: João Candido Rodrigues + Lhais Vergilio Delavy; Rocket: Luciane Gonzaga; Condomínio: Cristiano Silverio; Pessoa Física IRPF: Daniel Gustavo Manenti; Gerentes: Elizangela de Paula Kuhn) e gerada uma apuração de teste de verdade com os 2 arquivos reais — distribuição resultante: Fisco/Contábil 32, Gerentes 5, Rocket 1, Condomínio 4, Pessoa Física IRPF 1 (só os colaboradores de Fisco/Contábil ganharam respostas de critério, já que só esse departamento tem critérios cadastrados — esperado). Tudo (apuração de teste, departamentos de teste, relações de teste) removido depois do teste, mantendo só o "Fisco/Contábil" real criado pela migração.
Limitação real encontrada nesse teste, documentada em CLAUDE.md: resolver o departamento pelo gerente (não mais pelo colaborador individual) quebra o caso de uma gerente que supervisiona pessoas de departamentos diferentes — "Elizangela de Paula Kuhn" supervisiona os líderes de Fisco/Contábil e Luciane Gonzaga (que deveria cair em "Rocket", não em "Gerentes" junto com os outros). Como não existe mais uma exceção por colaborador individual (o antigo IndicadorSetorApelido cobria isso), Luciane Gonzaga passou a cair em "Gerentes" nesse teste — diferente do que a rodada 41 tinha estabelecido pra ela (Rocket). Sinalizado ao usuário como limitação conhecida do novo desenho; não corrigido nesta rodada por não ter sido pedido, e porque reintroduzir uma exceção por colaborador contrariaria a decisão de "só um mecanismo" tomada no início desta rodada — só mexer nisso se o usuário confirmar que quer.
46. Indicador de Desempenho: sugestões de gerente no popup "Gerenciar Gerentes"
Pedido, testando a rodada 45: o campo "Adicionar gerente" era só texto livre — o usuário pediu pra já vir preenchido com os nomes de gerente encontrados na última apuração, pra só precisar relacionar (clicar) em vez de redigitar cada nome (risco real de typo, já que o nome precisa bater exatamente com a coluna "gerente" da planilha pra apuração futura casar com o departamento certo).
Implementado 100% no frontend, sem endpoint novo: carregarGerentesSugeridos() busca a apuração mais recente (GET /api/indicadores-apuracoes/, a lista já vem ordenada por -competencia/-criado_em via IndicadorApuracao.Meta.ordering — não precisou de parâmetro novo), pega o detalhe dela (GET /api/indicadores-apuracoes/{id}/) e extrai os nomes distintos de colaborador.gerente, excluindo os que já estão em departamentoGerentesConfig (já mapeados pra algum departamento). O popup "Gerenciar Gerentes" ganhou uma seção "Sugestões" entre a lista atual e o campo de texto — cada sugestão é um botão (.checklist-item reaproveitado como <button>, mesmo padrão já usado em "Corrigir Responsável"/.ind-corrigir-resultado) que, ao ser clicado, já chama POST /api/indicadores-departamentos-gerentes/ pra aquele departamento. Lista recarregada (carregarGerentesSugeridos() de novo) depois de qualquer adição/remoção de gerente, em qualquer departamento, pra manter as sugestões sempre refletindo quem ainda falta mapear. O campo de texto livre continua disponível, pra gerentes que não apareceram na última apuração (colaborador novo, ainda sem apuração processada).
Validado com a apuração real do ambiente: as sugestões bateram exatamente com os 6 gerentes conhecidos (João Candido Rodrigues, Lhais Vergilio Delavy, Cristiano Silverio, Daniel Gustavo Manenti, Luciane Gonzaga, Elizangela de Paula Kuhn), já que nenhum deles está mapeado ainda no ambiente.
47. Bug: SuspiciousFileOperation ao anexar arquivo com nome muito longo na Importação de Plano de Saúde
Testando em produção real, upload de um arquivo da operadora com nome de arquivo original bem longo (ex.: "LEIAUTE_IMPORTACAO_DESPESAS_MEDICAS_EMP_92_PRESCINOTTI_CIA_LTDA...OPER_5060_UNIMED_DO_ESTADO_DO_PARANA_-_FEDERACAO_ESTADUAL_DA.CSV") deu 400 com django.core.exceptions.SuspiciousFileOperation: Storage can not find an available filename ... Please make sure that the corresponding file field allows sufficient "max_length".
Causa: ImportacaoPlanoSaude.planilha_padrao/arquivo_operadora (FileField) não tinham max_length explícito — o padrão do Django é 100, insuficiente pra upload_to="planos_saude/planilha_padrao/" (ou .../operadora/) somado a um nome de arquivo original longo (nome de arquivo real do cliente, fora do controle do Portal) + o sufixo que a storage acrescenta pra evitar colisão.
Corrigido definindo max_length=255 nos dois campos (portal_api/models.py, migração 0036_alter_importacaoplanosaude_arquivo_operadora_and_more, aplicada no ambiente local). Mesmo cuidado vale pra qualquer FileField/ImageField novo que aceite nome de arquivo originado fora do Portal (upload do usuário) — o padrão de 100 caracteres do Django é apertado demais pra nomes de arquivo reais de operadoras/clientes.
48. Banco de regras de custeio salvas na Importação de Plano de Saúde
Pedido do usuário, testando a importação da empresa 92 (Unimed): em vez de exportar/importar um arquivo .json com a regra de custeio preenchida (mecanismo puramente client-side, sem persistência — nada guardado no banco, sem nome, sem observação), ele queria um banco de regras de verdade: salvar a configuração usada como "092 - Unimed", escolhê-la numa lista em importações futuras, poder editá-la depois e anexar uma observação livre (ex.: "Empresa não desconta plano do empregado XX").
O que mudou:
- Model novo
RegraCusteioPlanoSaude(migração0037_regracusteioplanosaude) —nome/operadora/tipos_lancamento/custeio_por_tipo(mesmo formato dos campos homônimos deImportacaoPlanoSaude) +observacoes(texto livre) +criado_por/criado_em/atualizado_em. Lista compartilhada, sem "dono", mesma permissão de toggle único da ferramenta (PermissaoApp("utilitarios", "importacao-plano-saude")). RegraCusteioPlanoSaudeViewSet(CRUD completo, GET/POST/PATCH/DELETE) registrado em/api/regras-custeio-plano-saude/, seguindo o mesmo padrão deIndicadorPercentualTipoViewSet(perform_creategravacriado_por).- Validação de custeio extraída pra uma função compartilhada (
_monta_regra_custeio(),serializers.py) — antes só existia dentro deImportacaoPlanoSaudeCreateSerializer._valida_regra_especifica(); extraída pra módulo-level e reaproveitada porRegraCusteioPlanoSaudeSerializer.validate(), pra não duplicar a regra de negócio (parsing BR, faixa 0–100 do percentual, "ao menos limite ou percentual") em dois serializers que podiam divergir com o tempo.ImportacaoPlanoSaudeCreateSerializerfoi refatorado pra chamar essa mesma função — comportamento idêntico, validado com teste manual comparando a saída antes/depois do refactor. - Round-trip float↔texto BR: uma regra salva volta do
GETcomlimite_valor/percentualjá comofloat(formato final persistido), mas a validação de entrada só entende texto BR ("150,00")._valor_custeio_para_texto_br()normaliza um float de volta pra BR (viaformata_valor_br, já existente emleiaute_sistema.py) antes de repassar pro parser — sem isso, reenviar uma regra sem editar o custeio (ex.: só corrigindo o nome) corromperia o valor ("150.0"seria lido como 15000 porparse_valor_br, que remove pontos como separador de milhar). Validado via shell: criar uma regra, pegarvalidated_datade volta e revalidar como se fosse um update sem mudanças reproduz exatamente o mesmo resultado. - Frontend (
importacao-plano-saude.js/.html/.css): a seção "Regra de custeio" do formulário de Nova Importação trocou os botões "Exportar regra"/"Importar regra" por um<select>de regras salvas + "Aplicar" (preenche o formulário inteiro, incluindo a operadora se ainda existir na lista —aplicarRegraNoFormulario()), "Salvar regra atual..." (abre#ips-regra-save-modalpra nomear/descrever, nascendo em modo "atualizar" quando a regra aplicada ainda existe, com uma checkbox pra virar "criar nova" em vez de sobrescrever) e "Ver regras salvas" (#ips-regras-modal, lista com Aplicar/Excluir por linha). "Editar" uma regra não é uma tela separada — é aplicar, ajustar o que quiser nos campos normais do formulário, e salvar de novo (decisão deliberada pra não duplicar a grade de custeio dentro de um segundo modal). A validação de "custeio completo pros tipos marcados" (mensagemErroCusteio()) foi extraída do handler do botão "Processar" pra ser reaproveitada por "Salvar regra atual..." também. - Testado via Django test client (shell): criar/listar/atualizar/excluir uma regra pelo endpoint real, e confirmado que
criado_porgrava certo. - Ajuste de posição, no mesmo dia: a pedido do usuário, a seção "Regra de custeio salva" moveu do final do formulário (depois de "Tipo de importação") pro início, antes até de "Operadora" — já que aplicar uma regra também preenche a operadora, faz mais sentido esse ser o primeiro passo do fluxo. A borda de separação (
.ips-regra-field) virouborder-bottom(eraborder-top), já que agora separa do campo abaixo (Operadora), não de cima.
49. Quarta operadora: Dental Uni Odonto
Usuário forneceu um PDF real ("1084 - RELATORIO DENTAL UNI 072026.pdf", relatório "BENEFICIÁRIOS") + a planilha padrão (leiaute Questor) já casada como referência, descrevendo o formato: coluna "Beneficiário" traz titular e dependentes juntos (dependentes com indentação um pouco maior), sem CPF pra ninguém, coluna "Valor Unit" é o valor a custear/descontar de cada um.
- Novo parser
operadoras/dental_uni/odonto_mensalidade.py(DentalUniOdontoMensalidade), registrado empipeline.OPERADORAScomodental_uni_odonto_mensalidade/"Dental Uni Odonto".chave_casamento = "nome"(sem CPF no arquivo, igual Unimed/Itamed) — só mensalidade (sem coluna de coparticipação nesse relatório). - Titular vs dependente por indentação, não por rótulo: ao contrário da Itamed (que tem "Titula"/"Dependente" escrito no início da linha), este relatório não rotula nada — só indenta o texto do dependente um pouco mais que o do titular. O parser resolve isso comparando a indentação de cada linha com a indentação da primeira linha de beneficiário do arquivo (sempre um titular, por construção do relatório): igual ou menor → Titular; maior → Dependente.
- Nome quebrado em duas linhas: um titular do próprio exemplo ("SUZILAINE ZENATTI MEYER BEZERRA") tem o nome longo o bastante pra quebrar em duas linhas físicas no texto extraído do PDF, com o "[Nº Cartão]" só aparecendo na linha seguinte. O parser acumula linhas "órfãs" que parecem nome (só letras maiúsculas/espaços — nomes no relatório vêm 100% em caixa alta, o que distingue confiavelmente uma continuação de nome de qualquer outro texto do PDF, que nunca vem inteiramente maiúsculo) até encontrar a linha com o cartão, e usa a indentação da PRIMEIRA linha do bloco (não a da linha do cartão) pra decidir titular/dependente.
- Extração do valor por padrão, não por posição de coluna: como o número de datas antes do "Valor Unit" pode variar (ex.: uma linha com Data Exclusão preenchida teria uma data a mais), o parser não conta colunas — pega sempre o PRIMEIRO número no formato monetário (vírgula decimal) depois do "[Nº Cartão]", já que datas (
dd/mm/aaaa) nunca coincidem com esse padrão. A coluna "Total Fam" (só preenchida na linha do titular, soma da família) é ignorada de propósito, mesmo espírito da "Valor Total" da Amil. - Validado com o exemplo real (via script no shell do Django, não pelo formulário — ver caveat abaixo): os 11 lançamentos do PDF (4 famílias) foram extraídos corretamente, incluindo o nome quebrado em duas linhas, e o casamento com a planilha padrão fornecida bateu certo para 10 dos 11 — o 11º ("HELOISA NUNEZ RAMBO" no PDF vs "HELOISA NUNES RAMBO" na planilha, uma divergência real entre os dois arquivos de exemplo) caiu corretamente em auditoria (
NOME_DIVERGENTE), exatamente o comportamento esperado (nunca resolvido por aproximação automática). - Caveat importante: o parser foi escrito a partir do texto extraído do PDF mostrado na conversa, sem rodar o
pdfplumberde verdade contra o arquivo binário (não ficou salvo em nenhum lugar acessível pelo ambiente de desenvolvimento). A indentação exata que opdfplumbercomlayout=Truevai produzir pro PDF real pode diferir da observada — o parser usa indentação relativa (comparada com a primeira linha do próprio arquivo, não um número fixo) exatamente para tolerar isso, mas só validar de verdade com o botão "Selecionar arquivo" (2. Arquivo da operadora) da tela de Nova Importação, que já chama esse parser isoladamente via/importacoes-plano-saude/validar-arquivo/sem precisar de uma importação completa — mesmo caminho que a Unimed também vai precisar percorrer antes de ter um PDF real (hoje_extrai_pdfda Unimed é só umNotImplementedErrorexplícito por esse motivo).
50. Dois bugs corrigidos testando a Dental Uni com o PDF real
Dois problemas apareceram ao testar de fato (o "caveat" da rodada 49 se confirmou útil):
- Regressão em
ImportacaoPlanoSaudeDetailSerializer(afetava TODAS as operadoras, não só a Dental Uni):POST /api/importacoes-plano-saude/dava 500 (AttributeError: 'ImportacaoPlanoSaudeDetailSerializer' object has no attribute 'get_resumo_por_tipo') — o frontend mostrava só "Erro ao processar a solicitação." (mensagem genérica quepidErrorMessageFrom()usa quando a resposta não é JSON, verapi.js). Causa: ao inserirRegraCusteioPlanoSaudeSerializerlogo depois deImportacaoPlanoSaudeDetailSerializerna rodada 48, o métodoget_resumo_por_tipo()(que já existia, definido depois doclass Metada primeira classe) ficou fisicamente entre as duas — como Python não usa chaves pra delimitar classe, ele passou a pertencer à classe nova (RegraCusteioPlanoSaudeSerializer) por indentação, não à original. Corrigido movendo o método de volta pro lugar certo. Validado recriando uma importação completa via shell e conferindo que a serialização da resposta não quebra mais, além de reconfirmar que o CRUD de regras de custeio continua funcionando. - Ordem de junção do nome quebrado em duas linhas estava invertida: testando com o PDF real, "SUZILAINE ZENATTI MEYER" (titular) ficou sem "BEZERRA" (foi pra auditoria como pessoa não cadastrada, exigindo vínculo manual) e o dependente seguinte virou "BEZERRA JOAO LUCAS MEYER BEZERRA" (nem dava pra vincular, porque não existe ninguém com esse nome na planilha nem parecido o suficiente). A hipótese original (baseada só na inspeção visual do PDF, sem rodar o
pdfplumberde verdade) era que o "[Nº Cartão]" e os valores apareciam depois de todas as linhas do nome; o comportamento real dopdfplumberé o oposto — o cartão/valores ficam grudados na primeira linha do nome, e o excedente (quando o nome quebra) sobra sozinho numa linha própria depois, antes do próximo beneficiário. Corrigido invertendo a lógica: cada linha com "[Nº Cartão]" agora é processada na hora (não espera nada depois dela); uma linha órfã em CAIXA ALTA sem colchete é anexada ao nome do último lançamento já adicionado (nunca ao próximo). Revalidado com um teste reproduzindo a estrutura real (cartão na linha do "SUZILAINE ZENATTI MEYER", "BEZERRA" sozinho na linha seguinte, "JOAO LUCAS MEYER BEZERRA" depois) — os 11 beneficiários das 4 famílias voltaram a bater certo, incluindo o titular com nome quebrado reconstituído corretamente e o dependente seguinte sem o prefixo indevido.
Lição prática: sem o PDF real rodando de fato no pdfplumber, a extração de texto mostrada por inspeção visual pode enganar sobre a ORDEM em que o excedente de uma célula quebrada aparece — vale sempre desconfiar de qualquer heurística de "juntar linhas" escrita sem testar contra o parser de verdade.
51. Bug (não específico da Dental Uni): planilha padrão em Windows-1252 quebrava a leitura
Testando com uma segunda empresa (planilha padrão com "SOPHIA FERNANDES GONÇALVES", um nome com "Ç"), o campo "1. Planilha padrão (Questor)" recusava o arquivo com "Este arquivo não parece ser a planilha padrão exportada do Questor...", mesmo o CSV tendo exatamente o cabeçalho esperado.
Causa: le_planilha_padrao() (leiaute_sistema.py) sempre abria o arquivo como encoding="utf-8-sig", fixo. A planilha exportada do Questor, quando tem algum nome com acento, às vezes sai em Windows-1252/ANSI, não UTF-8 — decodificar um byte como 0xC7 ("Ç" em cp1252) como UTF-8 estoura UnicodeDecodeError. E como _valida_planilha_padrao()/create() capturam qualquer exceção genericamente (pra dar uma mensagem amigável quando o arquivo realmente está errado), o erro real (encoding) ficava escondido atrás da mensagem "não parece ser a planilha padrão" — nada a ver com o leiaute de colunas em si, que estava certo.
Corrigido com um fallback de encoding, mesmo espírito do encoding="latin-1" que o parser CSV da Unimed já usa: _decodifica_planilha() (nova função em leiaute_sistema.py) lê os bytes crus e tenta utf-8-sig primeiro (não muda nada pro caso comum sem acento, onde os bytes são idênticos nos dois formatos); só cai pra cp1252 se a decodificação UTF-8 falhar. le_planilha_padrao() passou a ler de um io.StringIO sobre esse texto já decodificado, em vez de abrir o arquivo diretamente com um encoding fixo. Validado com teste cobrindo os 3 casos (UTF-8 sem BOM, UTF-8 com BOM, cp1252) e reproduzindo o arquivo real do usuário (17 beneficiários, valida certo agora).
Vale a mesma observação de robustez pro arquivo_operadora de qualquer operadora nova baseada em CSV (a Unimed já se protegeu disso; Dental Uni é PDF, não é afetada) — se aparecer o mesmo tipo de erro genérico de "formato não reconhecido" pra um CSV com acento, suspeitar de encoding antes de desconfiar do leiaute de colunas.
52. Quinta operadora: Unimed Oeste do Paraná
Usuário forneceu um PDF real ("Demonstrativo Junho.2026.pdf", "Resumo de Faturamento" emitido pela ACIME — associação comercial que fatura em nome da Unimed Oeste do Paraná) + a planilha padrão correspondente, descrevendo o formato: empregados e dependentes aparecem na coluna "Serviço/Produto", o TIPO (mensalidade/coparticipação) também é decidido por essa mesma coluna ("Convenio Unimed" = mensalidade, o resto = coparticipação), e o valor usado é "Val. Total".
- Novo parser
operadoras/unimed_oeste_pr/saude.py(UnimedOestePrSaude), registrado empipeline.OPERADORAScomounimed_oeste_pr_saude/"Unimed Oeste do Paraná" — deliberadamente separado dounimed_saudejá existente, apesar do nome parecido: aquele espera um CSV com colunas próprias ("Id. Benef."/"Tipo Benef.", export direto da Unimed), este é um PDF de fatura da ACIME com um formato completamente diferente (nem CPF nem coluna de tipo dedicada).chave_casamento = "nome"(sem CPF no arquivo). - Cada pessoa pode ter mais de um "Nro." (contrato) — ex.: "ALINE PATRICIA RAMOS" aparece em dois blocos "(T) ALINE PATRICIA RAMOS - Nro.: ..." com números de contrato diferentes (um pro plano base/Convênio, outro pro Aditivo de resgate aéreo). Por isso o parser agrupa por NOME (não por "Nro.", que varia por contrato da mesma pessoa), diferente de todas as operadoras anteriores que usavam um número de carteirinha/cartão estável por pessoa.
- Tipo de lançamento decidido pelo texto da própria descrição, linha a linha (não por bloco/contrato inteiro): dentro do MESMO bloco "Nro.", a linha "Convenio Unimed..." conta como mensalidade e a linha "Taxa Administrativa Unimed..." — que fica junto, no mesmo contrato — conta como coparticipação, por instrução explícita do usuário ("Convenio Unimed é o valor de mensalidade e os demais são coparticipação"). Sinalizado ao usuário como algo a confirmar — não é o desenho mais intuitivo (taxa administrativa normalmente anda junto do valor de mensalidade), mas foi implementado ao pé da letra da instrução recebida.
- Duas variações de quebra de linha no PDF precisaram de tratamento: (a) quando a coluna "Prestador" está vazia (ex. "ADITIVO UNIMED AIR TERRESTRE..."), a descrição e os 3 números (Qtd/Val.Unit/Val.Total) saem em linhas físicas separadas — o parser junta uma linha-só-texto com a linha-só-números que vem logo depois; (b) quando a coluna "Prestador" tem texto longo (ex. "ASSOCIACAO MISSIONARIA DE BENEFICENCIA DAS IRMAS SERVAS DO E"), esse texto transborda pra linha(s) DEPOIS dos números já lançados — como não sobra número nenhum nessas linhas de transbordo, elas são descartadas sem gerar lançamento extra (não precisamos do conteúdo de "Prestador" pra nada).
- Validado com o PDF de exemplo completo: reproduzindo as 4 pessoas (1 família de uma pessoa só + 1 família com titular e 2 dependentes), a soma de todos os lançamentos bateu exatamente com o "Total Faturados: 4.628,37" impresso no próprio PDF — confirma que nenhuma linha foi perdida nem contada em dobro, inclusive nos dois casos de quebra de linha acima. Casamento com a planilha padrão também testado (mensalidade e coparticipação separadas): as 4 pessoas casaram automaticamente, 0 itens de auditoria.
- Mesmo caveat das duas últimas rodadas: escrito a partir do texto extraído mostrado na conversa, sem rodar o
pdfplumberde verdade contra o PDF binário — validar com o botão "Selecionar arquivo" antes de confiar em produção.
53. Regra de custeio salva: <select> virou combobox pesquisável
Com o banco de regras salvas crescendo (8 regras já cadastradas pelo usuário entre as 5 operadoras), o <select> nativo do campo "Regra de custeio salva" deixou de ser prático — sem busca, precisava rolar a lista inteira toda vez.
Trocado por um combobox pesquisável (#ips-regra-combo): um <input type="text"> (#ips-regra-search) que funciona tanto como campo de busca quanto como "display" do valor selecionado, com uma lista flutuante (#ips-regra-combo-list, position:absolute abaixo do input) que filtra pelas regras cujo nome contém o texto digitado (case-insensitive) — abre no foco (mostrando todas) e a cada tecla digitada; fecha ao clicar fora (listener de click no document, checando !ipsRegraCombo.contains(event.target)) ou ao escolher um item. Mesmo espírito de busca+lista já usado em "Vincular pessoa", só que aqui o campo de busca dobra como o "valor exibido" no lugar de uma <option> selecionada.
Estado novo em JS: regraSelecionadaId (o que está de fato escolhido no combobox — diferente de regraAplicadaId, que reflete o que está refletido nos CAMPOS do formulário). Digitar de novo no campo depois de já ter selecionado algo invalida regraSelecionadaId até o usuário clicar numa regra da lista — sem isso, "Aplicar" poderia aplicar uma regra antiga enquanto o texto exibido já era outra busca, incoerência que o <select> antigo não tinha (mudar o texto de um <select> só é possível escolhendo uma opção de verdade).
renderRegraSelect() (populava as <option>) foi substituída por renderRegraComboList(filtro); refreshRegras() deixou de re-renderizar um <select> inteiro e passou só a limpar a seleção se a regra escolhida tiver sido excluída em outro lugar enquanto isso (ex.: via o modal "Ver regras salvas").
Roadmap / próximos passos
Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida
da rodada 45 (Luciane Gonzaga caindo em "Gerentes" em vez de "Rocket" quando o
usuário mapear os gerentes de verdade — ver rodada 45) — cada rodada acima foi
fechada a pedido do usuário. Ao retomar o projeto, perguntar o que vem a
seguir em vez de assumir. As migrações já foram rodadas até a 0035
(rodada 45); o passo natural que falta é validar de ponta a ponta num
navegador de verdade um conjunto grande de funcionalidades que só foram
revisadas estaticamente ou testadas parcialmente: Links & Ferramentas
(rodada 16), a tela de Ramais completa (rodadas 21–23, 31–32), Acessos
Gerais (rodada 34) incluindo a restrição de seção por perfil e as
observações ricas com imagem, Eventos Corporativos no Calendário
Individual (rodada 35) com um perfil sem a permissão de criar evento,
Importação de Plano de Saúde (rodada 36) ponta a ponta com um arquivo
real de cada operadora, Simulação de Custo de Contratação (rodada 37)
conferindo o PDF gerado e a Lei 15.270/2025, e Indicador de Desempenho
(rodada 38) com uma apuração completa nova (fora do teste já feito com
os 43 colaboradores, que já validou o pipeline em si).