Compare commits

..

No commits in common. "ac654188351344c5795a37cdcd3567c2f080560a" and "2efde773f76c7ccad4cfb6a3fb8d76dae86fd2ba" have entirely different histories.

27 changed files with 877 additions and 966 deletions

View File

@ -12,28 +12,26 @@ Até uma rodada anterior, todo o estado (sessão, usuários, perfis de acesso, f
## Documentação dividida por aplicação
Este arquivo cobre o que é **transversal** ao Portal (arquitetura, modelo de permissões, API, CSS, animações). A partir de 2026-08-26, a documentação detalhada de cada aplicação foi movida pra fora daqui, pra reduzir conflito de edição quando mais de uma pessoa mexe em aplicações diferentes ao mesmo tempo. Ver `prd.md` pra visão de produto (o quê/pra quem), `README.md` na raiz pro mapa de todas as aplicações, e `plano.md` pro histórico de decisões **estruturais/transversais** (o histórico específico de cada aplicação vive no `CHANGELOG.md` dela, ver abaixo).
Cada aplicação tem uma pasta própria com (a) o detalhamento técnico do estado atual, (b) um `README.md` (resumo enxuto — o quê/pra quem, sem o detalhe técnico) e (c) um `CHANGELOG.md` (histórico rodada a rodada, extraído de `plano.md`, mesma numeração de rodada usada lá):
Este arquivo cobre o que é **transversal** ao Portal (arquitetura, modelo de permissões, API, CSS, animações). A partir de 2026-08-26, a documentação detalhada de cada aplicação foi movida pra fora daqui, pra reduzir conflito de edição quando mais de uma pessoa mexe em aplicações diferentes ao mesmo tempo. Ver `prd.md` pra visão de produto (o quê/pra quem) e `plano.md` pra histórico de decisões.
Aplicações com pacote Python próprio (`CLAUDE.md` **carregado automaticamente** pelo Claude Code ao trabalhar dentro da pasta):
| Aplicação | Onde |
|---|---|
| Importação de Plano de Saúde | `portal_api/planos_saude/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Indicador de Desempenho | `portal_api/indicadores/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
| Importação de Plano de Saúde | `portal_api/planos_saude/CLAUDE.md` |
| Indicador de Desempenho | `portal_api/indicadores/CLAUDE.md` |
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/CLAUDE.md` |
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — os arquivos em `docs/<app>/` **não** são carregados automaticamente, ler manualmente):
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — arquivo em `docs/`, **não** é carregado automaticamente, ler manualmente):
| Aplicação | Onde |
|---|---|
| Ramais (diretório, Telefones Externos, Funções de Telefonia, modal de consulta rápida) | `docs/ramais/` — `ramais.md` (técnico), `README.md`, `CHANGELOG.md` |
| Links & Ferramentas / Acessos Gerais | `docs/links-ferramentas-acessos-gerais/` — `links-ferramentas-acessos-gerais.md` (técnico), `README.md`, `CHANGELOG.md` |
| Calendário Individual e Widgets (incl. Eventos Corporativos, feriados, widgets de `portal.html`) | `docs/calendario-individual/` — `calendario-individual.md` (técnico), `README.md`, `CHANGELOG.md` |
| Favoritos (grade de `portal.html`) | `docs/favoritos/` — `favoritos.md` (técnico), `README.md`, `CHANGELOG.md` |
| Perfis de Acesso / Usuários (telas administrativas, Liderança, inativação) | `docs/perfis-usuarios/` — `perfis-usuarios.md` (técnico), `README.md`, `CHANGELOG.md` |
| Solicitações | `docs/solicitacoes/` — `solicitacoes.md` (técnico), `README.md`, `CHANGELOG.md` |
| Ramais (diretório, Telefones Externos, Funções de Telefonia, modal de consulta rápida) | `docs/ramais.md` |
| Links & Ferramentas / Acessos Gerais | `docs/links-ferramentas-acessos-gerais.md` |
| Calendário Individual e Widgets (incl. Eventos Corporativos, feriados, widgets de `portal.html`) | `docs/calendario-individual.md` |
| Favoritos (grade de `portal.html`) | `docs/favoritos.md` |
| Perfis de Acesso / Usuários (telas administrativas, Liderança, inativação) | `docs/perfis-usuarios.md` |
| Solicitações | `docs/solicitacoes.md` |
## Como rodar / testar localmente
@ -122,10 +120,10 @@ Um único app, `portal_api/`:
| Arquivo | Conteúdo |
|---|---|
| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver `docs/perfis-usuarios/perfis-usuarios.md`) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver `docs/ramais/ramais.md`), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria`/`ImportacaoPlanoSaudeAlteracao`/`VinculoNomeOperadora` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver `portal_api/planos_saude/CLAUDE.md`, inclusive "Vínculos de nome salvos (DE/PARA)"). |
| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver `docs/perfis-usuarios.md`) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; método `eh_perfil_inovacao()` — checagem de nome fixo pro perfil "Inovação", ver "Ajuda de aplicação" abaixo), `AjudaAplicacao` (texto de "Mais informações" de uma aplicação, chave natural `app_key`), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver `docs/links-ferramentas-acessos-gerais.md`), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver `docs/ramais.md`), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria`/`ImportacaoPlanoSaudeAlteracao`/`VinculoNomeOperadora` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver `portal_api/planos_saude/CLAUDE.md`, inclusive "Vínculos de nome salvos (DE/PARA)"). |
| `catalogo.py` | Fonte única da verdade do catálogo de módulos/aplicações do menu (`MODULES`, `MODULE_APPS`) — exposto só leitura via `GET /api/catalogo/`. Ao adicionar uma seção/aplicação nova ao menu, editar **aqui**, não em `static/js/profiles.js` (que só cacheia o payload recebido). |
| `serializers.py` | `PerfilAcessoSerializer`, `DepartamentoSerializer`, `UsuarioResumoSerializer` (`id`/`nome`/`departamentos`, usado nos dois lados de `liderados` e por `/api/usuarios-resumo/`), `UsuarioSerializer` (escrita, aceita `senha`+`perfis`+`departamentos`+`liderados`)/`UsuarioListSerializer` (leitura, `perfis`/`departamentos`/`liderados` aninhados), `CompromissoAgendaSerializer` (`sou_dono`, `dono_nome`, `dono_username`), `FavoritoSerializer`, `WidgetUsuarioSerializer`, `NotificacaoDispensadaSerializer`, `LinkFerramentaSerializer`, `LinkFerramentaFavoritoSerializer`, `AcessoGeralSecaoSerializer`, `AcessoGeralSerializer`, `RamalSerializer` (só das linhas avulsas — ver seção "Ramais"), `RamalAusenciaSerializer`, `TelefoneExternoSerializer`, `FuncaoTelefoniaSerializer`, `ImportacaoPlanoSaudeCreateSerializer`/`ImportacaoPlanoSaudeListSerializer`/`ImportacaoPlanoSaudeDetailSerializer`/`ImportacaoPlanoSaudeLinhaSerializer`/`ImportacaoPlanoSaudeAuditoriaSerializer` (ver seção "Importação de Plano de Saúde"). |
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`/`docs/ramais/ramais.md`). |
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver `docs/links-ferramentas-acessos-gerais.md`/`docs/ramais.md`). |
| `views.py` | `login_view`/`logout_view`/`csrf_view` (auth por sessão), `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view` (ver seção "Liderança"), `departamentos_resumo_view` (ver "Ramais"), `catalogo_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas/links e ferramentas/favoritos de links e ferramentas/seções e linhas de Acessos Gerais/ramais/ausências de ramal/importações de plano de saúde e suas linhas. |
| `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). |
| `management/commands/seed_portal.py` | Recria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`; também realinha a sequence do Postgres por trás de `PerfilAcesso.codigo` (ver nota abaixo). |
@ -143,27 +141,27 @@ Um único app, `portal_api/`:
| `/api/auth/logout/` | POST | encerra sessão |
| `/api/me/` | GET | usuário logado + `perfis` + `departamentos` (os próprios, pra alimentar o seletor de "Meu departamento" do Calendário Individual) + `gerencia_permissoes` + `eh_perfil_inovacao` (perfil "Inovação" vinculado, ver "Ajuda de aplicação" abaixo) + `permissoes_efetivas` (união já calculada no servidor) |
| `/api/me/senha/` | POST | `{senha_atual, nova_senha}` |
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver `docs/perfis-usuarios/perfis-usuarios.md`) |
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver `docs/perfis-usuarios.md`) |
| `/api/catalogo/` | GET | módulos/aplicações/subgrupos do menu |
| `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver `docs/calendario-individual/calendario-individual.md` |
| `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver `docs/calendario-individual.md` |
| `/api/usuarios-resumo/` | GET | lista enxuta (`id`/`nome`) de usuários ativos — alimenta o seletor de liderados, sem exigir `gerencia_permissoes` (mesmo padrão de `/api/ramais/usuarios/`) |
| `/api/departamentos-resumo/` | GET | lista enxuta (`id`/`nome`) de departamentos — alimenta os botões de filtro do modal de consulta rápida de Ramais, exige só `ramais-visualizar` (não `gerencia_permissoes` como `/api/departamentos/`) |
| `/api/ajuda-aplicacoes/<app_key>/` | GET/PATCH | texto de "Mais informações" de uma aplicação (ver seção própria abaixo); GET livre a qualquer autenticado, PATCH exige `eh_perfil_inovacao` (perfil "Inovação", checagem de nome fixo, não uma flag em Perfis de Acesso) |
| `/api/perfis/`, `/api/perfis/{codigo}/` | GET/POST/PUT/DELETE | CRUD de perfil — só quem tem `gerencia_permissoes` |
| `/api/departamentos/`, `/api/departamentos/{id}/` | GET/POST/PUT/DELETE | CRUD de departamento — só quem tem `gerencia_permissoes`; usado pela tela de Usuários pra listar o checklist e cadastrar um novo departamento inline (sem tela própria) |
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver `docs/perfis-usuarios/perfis-usuarios.md`) |
| `/api/compromissos/`, `/api/compromissos/{id}/` | GET/POST/PATCH/DELETE | GET já retorna só o que o usuário logado pode ver (próprios + `visibilidade="todos"` + `visibilidade="departamento"` com departamento em comum); campo `notificar_em` (calculado, ver `docs/calendario-individual/calendario-individual.md`) indica quando o lembrete passa a valer; criar/editar com `visibilidade` em `departamento`/`todos` (inclusive todo `eh_evento=True`, que força `visibilidade="todos"`) exige `apps["calendario-individual-criar-evento"]` (ver "Eventos Corporativos" abaixo) |
| `/api/categorias-evento/`, `/api/categorias-evento/{id}/` | GET/POST/PATCH/DELETE | cadastro de categorias de evento (`nome`+`cor`) usado pelo Calendário Individual; GET livre a qualquer autenticado, escrita exige `apps["calendario-individual-criar-evento"]` — ver `docs/calendario-individual/calendario-individual.md` |
| `/api/favoritos/`, `/api/favoritos/{app_id}/` | GET/POST/PATCH/DELETE | chave natural é `app_id`, não um id numérico; `ordem` é gravável via PATCH (drag-and-drop na grade de favoritos, ver `docs/favoritos/favoritos.md`) |
| `/api/widgets/`, `/api/widgets/{tipo}/` | GET/POST/PATCH/DELETE | chave natural é `tipo`; `ordem` (reordenar por drag-and-drop) e `largura`/`altura` em px (redimensionamento) também são graváveis via PATCH — ver `docs/calendario-individual/calendario-individual.md` |
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver `docs/perfis-usuarios.md`) |
| `/api/compromissos/`, `/api/compromissos/{id}/` | GET/POST/PATCH/DELETE | GET já retorna só o que o usuário logado pode ver (próprios + `visibilidade="todos"` + `visibilidade="departamento"` com departamento em comum); campo `notificar_em` (calculado, ver `docs/calendario-individual.md`) indica quando o lembrete passa a valer; criar/editar com `visibilidade` em `departamento`/`todos` (inclusive todo `eh_evento=True`, que força `visibilidade="todos"`) exige `apps["calendario-individual-criar-evento"]` (ver "Eventos Corporativos" abaixo) |
| `/api/categorias-evento/`, `/api/categorias-evento/{id}/` | GET/POST/PATCH/DELETE | cadastro de categorias de evento (`nome`+`cor`) usado pelo Calendário Individual; GET livre a qualquer autenticado, escrita exige `apps["calendario-individual-criar-evento"]` — ver `docs/calendario-individual.md` |
| `/api/favoritos/`, `/api/favoritos/{app_id}/` | GET/POST/PATCH/DELETE | chave natural é `app_id`, não um id numérico; `ordem` é gravável via PATCH (drag-and-drop na grade de favoritos, ver `docs/favoritos.md`) |
| `/api/widgets/`, `/api/widgets/{tipo}/` | GET/POST/PATCH/DELETE | chave natural é `tipo`; `ordem` (reordenar por drag-and-drop) e `largura`/`altura` em px (redimensionamento) também são graváveis via PATCH — ver `docs/calendario-individual.md` |
| `/api/notificacoes-dispensadas/`, `/api/notificacoes-dispensadas/{notif_id}/` | GET/POST/DELETE | chave natural é `notif_id` (ex.: `"tool-widgets"`, `"event-42"`); `notifications.js` usa GET pra filtrar o que já foi dispensado e POST a cada X/"Limpar tudo" |
| `/api/links-ferramentas/`, `/api/links-ferramentas/{id}/` | GET/POST/PATCH/DELETE | lista **compartilhada** (não por usuário); leitura exige `apps["links-ferramentas-visualizar"]` e escrita exige `apps["links-ferramentas-editar"]` em `permissoes["links-ferramentas"]` (`PermissaoApp`, gate por método em `get_permissions()` — ver "Modelo de permissões" abaixo); POST é `multipart/form-data` (aceita upload de `icone`); `ordem` sempre é atribuída pelo servidor na criação (ignora o que vier no payload), reordenar é PATCH trocando o `ordem` de dois itens |
| `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado (ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`) |
| `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário (ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`) |
| `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) — ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md` |
| `/api/ramais/` | GET | diretório **mesclado**: todo usuário ativo (linha montada do cadastro, sem precisar de nenhum registro extra) + linhas avulsas de `Ramal`; leitura exige `apps.visualizar` — ver `docs/ramais/ramais.md` |
| `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado (ver `docs/links-ferramentas-acessos-gerais.md`) |
| `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário (ver `docs/links-ferramentas-acessos-gerais.md`) |
| `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) — ver `docs/links-ferramentas-acessos-gerais.md` |
| `/api/ramais/` | GET | diretório **mesclado**: todo usuário ativo (linha montada do cadastro, sem precisar de nenhum registro extra) + linhas avulsas de `Ramal`; leitura exige `apps.visualizar` — ver `docs/ramais.md` |
| `/api/ramais/`, `/api/ramais/{id}/` | POST/PATCH/DELETE | CRUD só das linhas avulsas (`Ramal`, sem `Usuario` por trás); escrita exige `apps.editar` |
| `/api/ramais/usuarios/` | GET | lista enxuta (`id`/`nome`) de usuários ativos pra alimentar o `<select>` "Lista de Usuários" do modal de Criar Ausência — não é `/api/usuarios/` de propósito (ver `docs/ramais/ramais.md`) |
| `/api/ramais/usuarios/` | GET | lista enxuta (`id`/`nome`) de usuários ativos pra alimentar o `<select>` "Lista de Usuários" do modal de Criar Ausência — não é `/api/usuarios/` de propósito (ver `docs/ramais.md`) |
| `/api/ramais/usuarios/{usuario_id}/` | PATCH | `{numero}` — grava direto em `Usuario.ramal`; é como a tela edita o ramal de um colaborador de verdade (exige `apps.editar`) |
| `/api/ramais-ausencias/`, `/api/ramais-ausencias/{id}/` | GET/POST/PATCH/DELETE | períodos de ausência; "ausente agora" nunca é lido daqui direto pelo frontend, vem calculado em `usuario_ausente`/`usuario_ausencia_ativa_id` na listagem de `/api/ramais/`; `PATCH` com `{"encerrada_manualmente": true}` encerra antes do previsto |
| `/api/telefones-externos/`, `/api/telefones-externos/{id}/` | GET/POST/PATCH/DELETE | subtela "Telefones Externos" de `ramais.html`; mesma permissão `PermissaoApp("ramais", ...)` do diretório de Ramais |
@ -213,14 +211,14 @@ Um único app, `portal_api/`:
| `calendario-individual.html` | Agenda pessoal: grade mensal + modal de criar/editar compromisso. |
| `perfis-acesso.html` | CRUD de perfis de acesso (lista + edição com abas Permissões/Usuários do Escritório). |
| `usuarios.html` | CRUD de contas de usuário (lista + edição com checklist de perfis). |
| `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`). |
| `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`). |
| `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver `docs/ramais/ramais.md`). |
| `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver `docs/links-ferramentas-acessos-gerais.md`). |
| `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver `docs/links-ferramentas-acessos-gerais.md`). |
| `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver `docs/ramais.md`). |
| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver `portal_api/planos_saude/CLAUDE.md`). |
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
## Ordem de `<script>` e por que ela não quebra nada
@ -286,7 +284,7 @@ Alguns módulos não são só "lista de aplicações que aparecem ou não no men
],
```
`links-ferramentas` nasceu com só um par `visualizar`/`editar` flat (sem subgrupo, já que só existia uma aplicação na seção); virou dois subgrupos quando "Acessos Gerais" foi adicionado como uma segunda aplicação dentro da mesma seção — a mesma evolução que `ramais` já tinha passado antes (ver "Navegação por abas em `ramais.html`" em `docs/ramais/ramais.md`). Cada chave de `tool` é prefixada com o nome da aplicação (`links-ferramentas-visualizar`, não só `visualizar`) porque `permissoes[module_key]["apps"]` é um dict **achatado** — todas as `tools` de todos os subgrupos do módulo compartilham o mesmo namespace, então chaves genéricas colidiriam entre as duas aplicações.
`links-ferramentas` nasceu com só um par `visualizar`/`editar` flat (sem subgrupo, já que só existia uma aplicação na seção); virou dois subgrupos quando "Acessos Gerais" foi adicionado como uma segunda aplicação dentro da mesma seção — a mesma evolução que `ramais` já tinha passado antes (ver "Navegação por abas em `ramais.html`" em `docs/ramais.md`). Cada chave de `tool` é prefixada com o nome da aplicação (`links-ferramentas-visualizar`, não só `visualizar`) porque `permissoes[module_key]["apps"]` é um dict **achatado** — todas as `tools` de todos os subgrupos do módulo compartilham o mesmo namespace, então chaves genéricas colidiriam entre as duas aplicações.
Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`renderEntry()`/`renderLeaf()` em `profiles.js`, sem nenhum código de UI novo) — na tela de edição de perfil, "Links & Ferramentas" aparece expansível com "Links & Ferramentas" e "Acessos Gerais" como subgrupos, cada um expansível de novo em "Visualizar"/"Editar", do mesmo jeito que "Auditorias" mostra "Consultoria Tributária" → "Controle Simples Nacional". No backend, a checagem usa `Usuario.permissao_app(module_key, app_key)` (união entre os perfis vinculados, mesma lógica de `permissoes_efetivas()`) e a classe genérica `PermissaoApp(module_key, app_key)` em `permissions.py`, instanciada por view — nenhuma subclasse nova é necessária pra outro módulo/aplicação adotar o mesmo padrão, só instanciar com outra `app_key`.
@ -294,7 +292,7 @@ Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`r
**Bug real (rodada 69) — `IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey"` ao criar um perfil pela tela**: `seed_portal.py` semeia `PerfilAcesso` com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: `codigo == 8` = "Integração e Inovação"). No Postgres, um `INSERT` com PK explícita **nunca avança a sequence** por trás do `AutoField` — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (`POST /api/perfis/`, sem PK explícita) recebia `codigo=1` do `nextval()`, colidindo com um código já usado pelo seed. `_reset_sequence(model)` (função módulo-level em `seed_portal.py`, roda `SELECT setval(pg_get_serial_sequence(...), MAX(pk))` via SQL puro do Postgres) corrige isso, chamada logo depois do loop de `PERFIS_SEED` — toda vez que `seed_portal` roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um `loaddata`/`RunPython` de migração também inserindo `PerfilAcesso` com PK explícita sem passar por `seed_portal.py` depois), o comando pra corrigir manualmente é `python manage.py seed_portal` (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
Duas telas administrativas por cima desse modelo (popup "Nova Aplicação" de `perfis-acesso.html`, aba "Usuários do Escritório") estão documentadas em `docs/perfis-usuarios/perfis-usuarios.md`, não aqui.
Duas telas administrativas por cima desse modelo (popup "Nova Aplicação" de `perfis-acesso.html`, aba "Usuários do Escritório") estão documentadas em `docs/perfis-usuarios.md`, não aqui.
## Ajuda de aplicação ("Mais informações")
@ -319,37 +317,37 @@ Botão "?" (`.info-tooltip`, `components.css`) ao lado do nome de uma aplicaçã
- **Só ligado em Importação de Plano de Saúde por ora** (`ips-ajuda-btn` em `importacao-plano-saude.html`, ao lado do `<h2>` dentro de `.ips-title-row`) — o mecanismo (model/endpoint/JS) já é genérico o bastante pra outra aplicação nova só precisar do botão+tooltip no HTML e uma chamada a `pidCriarBotaoAjuda()`, sem nenhum código novo no backend.
- Texto inicial de Importação de Plano de Saúde já estruturado (objetivo/como funciona/cuidados necessários/resultado esperado, em HTML simples — `<p>`/`<strong>`/`<ul>`/`<li>`) e salvo direto no banco, pronto pra revisão/edição do usuário pela própria tela (perfil Inovação).
A inativação/reativação de usuário (`is_active`, incluindo o desvínculo automático de perfis/liderança), a Liderança (gerente/coordenador) e o bug do `seed_portal.py` não resetar mais `nome` de um perfil já existente (ver `[[feedback_seed_nao_reseta_gabriel_bruno]]` na memória) estão documentados em `docs/perfis-usuarios/perfis-usuarios.md`.
A inativação/reativação de usuário (`is_active`, incluindo o desvínculo automático de perfis/liderança), a Liderança (gerente/coordenador) e o bug do `seed_portal.py` não resetar mais `nome` de um perfil já existente (ver `[[feedback_seed_nao_reseta_gabriel_bruno]]` na memória) estão documentados em `docs/perfis-usuarios.md`.
## Favoritos
Grade de aplicações favoritadas na tela Principal (`portal.html`) — o usuário marca itens do menu como favoritos e reordena os cards por drag-and-drop. O ID de cada favorito é derivado da própria estrutura do menu, não de um cadastro à parte.
Ver `docs/favoritos/favoritos.md`.
Ver `docs/favoritos.md`.
## Calendário Individual e Widgets
Agenda pessoal de cada usuário (compromissos privados, de departamento ou de todos), com eventos corporativos, feriados nacionais/estaduais do Paraná e lembretes calculados em horário comercial. Inclui também o sistema genérico de widgets configuráveis da tela Principal (drag-and-drop, redimensionamento).
Ver `docs/calendario-individual/calendario-individual.md`.
Ver `docs/calendario-individual.md`.
## Links & Ferramentas / Acessos Gerais
Duas aplicações na mesma seção do menu: "Links & Ferramentas" é uma grade de cartões de atalho para ferramentas externas; "Acessos Gerais" é um cadastro de logins/acessos compartilhados da equipe, organizado em seções e linhas.
Ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`.
Ver `docs/links-ferramentas-acessos-gerais.md`.
## Ramais
Diretório de ramais internos — mescla automaticamente todo colaborador ativo (a partir do cadastro de Usuários) com linhas avulsas (telefone de sala, recepção etc.). Inclui também Telefones Externos, Funções de Telefonia e controle de ausência.
Ver `docs/ramais/ramais.md`.
Ver `docs/ramais.md`.
## Solicitações
Os itens do menu "Solicitações" não são telas próprias — cada um é um link externo (hoje, um formulário do Asana) aberto em nova aba; não dá pra embutir em iframe porque o Asana bloqueia.
Ver `docs/solicitacoes/solicitacoes.md`.
Ver `docs/solicitacoes.md`.
## Simulação de Custo de Contratação (Geradoc)
@ -376,7 +374,7 @@ Ver `portal_api/planos_saude/CLAUDE.md`.
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo) e `.pid-icon-eye`/`pidIconBlink` (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver `docs/perfis-usuarios/perfis-usuarios.md` — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver `docs/perfis-usuarios.md` — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
| `widgets.css` | `.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`. O topbar da tela inicial não tem mais título/slogan nenhum (`<h1 id="portal-title">` — chegou a existir brevemente com o slogan "Grandes aplicações de todos os tamanhos" em fonte "Pinyon Script"/dourado, removido a pedido do usuário na mesma rodada; ver `login.css` abaixo pra onde o slogan acabou indo) — o `<link>` do Google Fonts em `portal.html` também foi removido junto, já que não sobrou nenhum uso de fonte customizada nessa página. |
@ -384,7 +382,7 @@ Ver `portal_api/planos_saude/CLAUDE.md`.
| `links-ferramentas.css` | `.lf-*` — só usado em `links-ferramentas.html`. |
| `acessos-gerais.css` | `.ag-*` — só usado em `acessos-gerais.html`. |
| `ramais.css` | `.ram-*` — só usado em `ramais.html`; a tabela em si reaproveita `.pa-table*`/`.pa-row-actions` de `perfis-acesso.css` (mesmo padrão que `usuarios.html` já usa sem CSS próprio). |
| `ramais-lookup.css` | `.ram-lookup-*` — modal de consulta rápida de ramais (ver `docs/ramais/ramais.md`), usado em `portal.html`/`links-ferramentas.html`/`calendario-individual.html`. Tabela **autocontida** (não reaproveita `.pa-table` porque essas 3 páginas não carregam `perfis-acesso.css`). |
| `ramais-lookup.css` | `.ram-lookup-*` — modal de consulta rápida de ramais (ver `docs/ramais.md`), usado em `portal.html`/`links-ferramentas.html`/`calendario-individual.html`. Tabela **autocontida** (não reaproveita `.pa-table` porque essas 3 páginas não carregam `perfis-acesso.css`). |
| `importacao-plano-saude.css` | `.ips-*` — só usado em `importacao-plano-saude.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.pa-table-wrap` na tabela editável da revisão e nas abas. |
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |

View File

@ -1,48 +0,0 @@
# Portal De Paula
Portal interno da De Paula Contadores — Python 3.13 + Django 6.0 + Django REST Framework + PostgreSQL 14, servindo tanto a API (`/api/...`) quanto o frontend HTML/CSS/JS (mesma origem). `Portal/` **é** o próprio projeto Django (`manage.py` na raiz), organizado no padrão convencional de um projeto Django.
## Como rodar localmente
```
cd Portal
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
Configurar as variáveis de ambiente do Postgres 14 antes de migrar — `config/settings.py` lê `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT` do `.env` (já existe na raiz de `Portal/`):
```
python manage.py makemigrations portal_api
python manage.py migrate
python manage.py seed_portal # recria os 8 perfis + usuários gabriel/bruno
python manage.py runserver
```
Abrir `http://localhost:8000/` — o Django serve `index.html` e as demais páginas do frontend diretamente, sem precisar de um segundo servidor pro frontend.
Contas de demonstração (criadas por `seed_portal`): `gabriel`/`gabriel` (perfil "Inovação", acesso total) e `bruno`/`bruno` (sem perfil vinculado, testa o estado "sem acesso").
Não há suíte de testes, lint ou build configurados neste projeto.
## Mapa das aplicações
| Aplicação | Seção do menu | Documentação |
|---|---|---|
| Principal (favoritos, widgets) | Principal | [Favoritos](docs/favoritos/README.md), [Calendário Individual e Widgets](docs/calendario-individual/README.md) |
| Calendário Individual | Calendário | [docs/calendario-individual/](docs/calendario-individual/README.md) |
| Links & Ferramentas / Acessos Gerais | Links & Ferramentas | [docs/links-ferramentas-acessos-gerais/](docs/links-ferramentas-acessos-gerais/README.md) |
| Ramais | Ramais | [docs/ramais/](docs/ramais/README.md) |
| Solicitações | Solicitações | [docs/solicitacoes/](docs/solicitacoes/README.md) |
| Perfis de Acesso / Usuários | Administração | [docs/perfis-usuarios/](docs/perfis-usuarios/README.md) |
| Importação de Plano de Saúde | Utilitários | [portal_api/planos_saude/](portal_api/planos_saude/README.md) |
| Simulação de Custo de Contratação | Geradoc | [portal_api/custo_contratacao/](portal_api/custo_contratacao/README.md) |
| Indicador de Desempenho | Geradoc | [portal_api/indicadores/](portal_api/indicadores/README.md) |
Cada pasta acima tem um `README.md` (o que a aplicação faz, estado atual) e um `CHANGELOG.md` (histórico rodada a rodada, extraído de `plano.md`). As 3 aplicações com pacote Python próprio também têm um `CLAUDE.md` com o detalhamento técnico completo, carregado automaticamente pelo Claude Code; as demais têm esse detalhe no arquivo dentro da própria pasta em `docs/`.
## Documentação transversal
- [CLAUDE.md](CLAUDE.md) — arquitetura geral do Portal (estrutura de pastas, modelo de permissões, API, CSS, animações). Carregado automaticamente pelo Claude Code.
- [plano.md](plano.md) — histórico de decisões estruturais/transversais (rodadas que mudaram mais de uma aplicação ao mesmo tempo, migração pra Django, modelo de permissões em si, identidade visual). Histórico específico de cada aplicação vive no `CHANGELOG.md` dela.

View File

@ -23,13 +23,13 @@ Ao escolher `visibilidade="departamento"` no modal (`calendario-individual.html`
No frontend (`calendar-individual.js`), `carregarFeriados(ano)` busca e cacheia por ano (`Map` em memória, só refaz a requisição ao trocar de ano); `render()` busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de `viewYear`. Cada célula de dia feriado ganha a classe `.is-feriado` (fundo tingido de vermelho, `rgba(var(--danger-rgb), 0.1)`) e um pill (`.calendar-day__holiday`, mesmo visual dos pills de compromisso — `.calendar-event`, só que com fundo `--danger` fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de `.calendar-day__header`, **fora** de `.calendar-day__events` — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com `text-overflow:ellipsis` quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por `;`, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver `feriados_view`); clicar no pill abre `#ic-holiday-modal` (`openHolidayModal()`, mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.
**Gerente/coordenador vê a agenda individual da equipe**: `CompromissoAgendaViewSet.get_queryset` acrescenta `Q(visibilidade="somente_eu", dono__in=usuario.liderados.all())` às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em `Usuario.liderados` (ver `docs/perfis-usuarios/perfis-usuarios.md`, seção "Liderança") também enxerga os compromissos privados (`"somente_eu"`) de cada liderado, mas sem poder editá-los (`sou_dono` continua `False` pra esses, `CompromissoAgendaViewSet.get_object` já barra escrita de quem não é dono). Não há necessidade de checar `usuario.lideranca` explicitamente na query — `liderados` só é populado através de fluxos que já exigem esse flag (ver `docs/perfis-usuarios/perfis-usuarios.md`), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.
**Gerente/coordenador vê a agenda individual da equipe**: `CompromissoAgendaViewSet.get_queryset` acrescenta `Q(visibilidade="somente_eu", dono__in=usuario.liderados.all())` às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em `Usuario.liderados` (ver `docs/perfis-usuarios.md`, seção "Liderança") também enxerga os compromissos privados (`"somente_eu"`) de cada liderado, mas sem poder editá-los (`sou_dono` continua `False` pra esses, `CompromissoAgendaViewSet.get_object` já barra escrita de quem não é dono). Não há necessidade de checar `usuario.lideranca` explicitamente na query — `liderados` só é populado através de fluxos que já exigem esse flag (ver `docs/perfis-usuarios.md`), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.
**Lembrete com horário comercial** (`CompromissoAgenda.lembrete_antecedencia`, opcional — `""` = sem lembrete; `"1h"`/`"2h"`/`"4h"`/`"24h"`): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (`notif-bell`), que já era recalculado a cada carregamento de página (sem processo em segundo plano). `CompromissoAgenda.calcular_notificar_em()` (`models.py`) calcula esse horário contando `lembrete_antecedencia` horas **de expediente** (seg-sex, 8h-18h, `COMPROMISSO_HORARIO_COMERCIAL_INICIO`/`_FIM`) pra trás a partir de `data`+`horario` — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (`_janela_comercial`/`_dia_util_anterior`, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem `horario` definido no compromisso (evento de dia inteiro) ou sem `lembrete_antecedencia`, não há o que calcular e `calcular_notificar_em()` retorna `None`. O resultado é exposto só leitura via `notificar_em` no serializer (ISO datetime ou `null`); `pidBuildEventNotifications()` (`notifications.js`) só inclui um compromisso na lista do sino quando `notificar_em` não é nulo **e** já foi atingido (`now >= notificar_em`) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).
## Widgets (`portal.html`)
`widgets.js` mantém um registro extensível `PID_WIDGET_TYPES` (`{ "<chave>": { label, description, href, linkLabel, visibleIf? } }`) — hoje existem `"calendario-individual"` e `"links-favoritos"` (ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`). `href`/`linkLabel` alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra `calendario-individual.html`, então ao adicionar um tipo novo **sempre** preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. `visibleIf(me)` é opcional — quando presente, filtra o tipo tanto do picker (`renderPicker()`) quanto da grade já adicionada (`renderWidgets()`), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: `links-favoritos` só aparece pra quem tem `apps["links-ferramentas-visualizar"]` em `links-ferramentas`). Para adicionar um novo tipo de widget: registrar a entrada em `PID_WIDGET_TYPES` e adicionar um `case`/`if` em `widgetBodyFor()` que retorne o HTML do corpo do card; o picker (`#widget-picker-modal`) e a grade (`#widgets-grid`) já lidam com adicionar/remover genericamente via `/api/widgets/`.
`widgets.js` mantém um registro extensível `PID_WIDGET_TYPES` (`{ "<chave>": { label, description, href, linkLabel, visibleIf? } }`) — hoje existem `"calendario-individual"` e `"links-favoritos"` (ver `docs/links-ferramentas-acessos-gerais.md`). `href`/`linkLabel` alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra `calendario-individual.html`, então ao adicionar um tipo novo **sempre** preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. `visibleIf(me)` é opcional — quando presente, filtra o tipo tanto do picker (`renderPicker()`) quanto da grade já adicionada (`renderWidgets()`), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: `links-favoritos` só aparece pra quem tem `apps["links-ferramentas-visualizar"]` em `links-ferramentas`). Para adicionar um novo tipo de widget: registrar a entrada em `PID_WIDGET_TYPES` e adicionar um `case`/`if` em `widgetBodyFor()` que retorne o HTML do corpo do card; o picker (`#widget-picker-modal`) e a grade (`#widgets-grid`) já lidam com adicionar/remover genericamente via `/api/widgets/`.
**Reordenar e redimensionar widgets** (`WidgetUsuario.ordem`/`largura`/`altura`, por usuário): `.widgets-grid` é `display:flex; flex-wrap:wrap` (não mais CSS Grid — precisava permitir que cada `.widget-card` tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (`dragstart`/`dragover`/`drop` em `#widgets-grid`, `PATCH /api/widgets/{tipo}/` só nos itens cujo `ordem` mudou) — a diferença é que o `draggable="true"` fica só em `.widget-card__header` (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (`resize: both` em `.widget-card`, ativo no canto inferior direito). Redimensionar usa esse `resize: both` do CSS (sem JS de arraste custom) — um `ResizeObserver` por card (`observeWidgetSizes()`) detecta a mudança de tamanho e salva `largura`/`altura` com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só `.widget-card__body` tem `overflow-y: auto` (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).

View File

@ -1,24 +0,0 @@
# Changelog — Calendário Individual e Widgets
> Histórico específico desta aplicação, extraído de `plano.md`. Rodadas que mudaram mais de uma aplicação ao mesmo tempo (modelo de permissões, CSS transversal) continuam em `plano.md` — a numeração de rodada é a mesma usada lá, para referência cruzada.
### Rodada 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.
### Rodada 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` + `pidEventsVisibleTo` em `events.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_TYPES` já está estruturado para novos tipos sem refazer a área de adicionar/remover.
### Rodada 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.
### Rodada 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 técnico completo em `docs/calendario-individual/calendario-individual.md`, seção "Eventos Corporativos".

View File

@ -1,15 +0,0 @@
# Calendário Individual e Widgets
> Ver `docs/calendario-individual/calendario-individual.md` para o detalhamento técnico. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
Agenda pessoal de cada colaborador (`calendario-individual.html`): grade mensal de compromissos privados, de departamento ou de todos, mais eventos corporativos formais (reuniões, treinamentos), feriados nacionais/estaduais do Paraná e lembretes calculados em horário comercial. Gerentes/coordenadores também enxergam os compromissos privados de quem lideram (sem poder editá-los).
Inclui também o sistema genérico de **Widgets** da tela Principal (`portal.html`) — área configurável (adicionar/remover/reordenar/redimensionar) onde hoje só existe o widget de Calendário Individual (próximos compromissos) e o de Links Favoritos, mas a estrutura já suporta novos tipos sem refazer a UI.
O item de menu "Calendário De Paula" não é uma tela local — é um redirecionador (modal com `<iframe>`) para `https://depaula-tvcorporativa.lovable.app/calendario`, mantido separado do Calendário Individual.
## Onde mexer
- `calendario-individual.html` / `static/js/calendar-individual.js` / `static/css/calendario.css`.
- `CompromissoAgenda`, `CategoriaEvento` (`portal_api/models.py`) + `/api/compromissos/`, `/api/categorias-evento/`, `/api/feriados/`.
- `WidgetUsuario` (`portal_api/models.py`) + `/api/widgets/` + `static/js/widgets.js` + `static/css/widgets.css`.

View File

@ -7,8 +7,8 @@
- Se o `<li>` do link já tem `data-section`, o ID é esse valor (ex.: `"ramais"`).
- Caso contrário (é um sub-item dentro de um `nav-group`), o ID é `"<data-section do grupo pai>__<slug do texto do link>"` (ex.: `"portais__portal-do-cliente"`).
O slug (`pidSlug`) normaliza acentos (NFD) e troca sequências de caracteres não `[a-z0-9]` por `-`. Se o texto de um label mudar, o `app_id` derivado muda junto (favoritos existentes referenciando o ID antigo deixam de casar) — mesmo caveat vale se a estrutura do menu mudar (ex.: um item vira `nav-group` expansível, ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`, "Links & Ferramentas": um favorito antigo `app_id === "links-ferramentas"` parou de casar quando a seção virou dois sub-itens).
O slug (`pidSlug`) normaliza acentos (NFD) e troca sequências de caracteres não `[a-z0-9]` por `-`. Se o texto de um label mudar, o `app_id` derivado muda junto (favoritos existentes referenciando o ID antigo deixam de casar) — mesmo caveat vale se a estrutura do menu mudar (ex.: um item vira `nav-group` expansível, ver `docs/links-ferramentas-acessos-gerais.md`, "Links & Ferramentas": um favorito antigo `app_id === "links-ferramentas"` parou de casar quando a seção virou dois sub-itens).
**Reordenar os cards favoritos**: `Favorito.ordem` (`PositiveIntegerField`, `Meta.ordering = ["ordem", "id"]`) — mesmo padrão de `LinkFerramenta`/`WidgetUsuario`: `FavoritoViewSet.perform_create` atribui `ordem = max(ordem atual do usuário) + 1`, e reordenar é drag-and-drop nativo em `#app-card-grid` (`favorites.js`, `dragstart`/`dragover`/`drop`, `PATCH /api/favoritos/{app_id}/` só nos itens cujo `ordem` mudou) — mesma mecânica de Widgets/Links & Ferramentas (ver `docs/calendario-individual/calendario-individual.md`/`docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`). `.app-card` inteiro é `draggable="true"` (não precisa de um handle separado como os widgets, já que não tem `resize` pra conflitar); o botão de remover (`.app-card__remove`) é `draggable="false"` pra não interferir.
**Reordenar os cards favoritos**: `Favorito.ordem` (`PositiveIntegerField`, `Meta.ordering = ["ordem", "id"]`) — mesmo padrão de `LinkFerramenta`/`WidgetUsuario`: `FavoritoViewSet.perform_create` atribui `ordem = max(ordem atual do usuário) + 1`, e reordenar é drag-and-drop nativo em `#app-card-grid` (`favorites.js`, `dragstart`/`dragover`/`drop`, `PATCH /api/favoritos/{app_id}/` só nos itens cujo `ordem` mudou) — mesma mecânica de Widgets/Links & Ferramentas (ver `docs/calendario-individual.md`/`docs/links-ferramentas-acessos-gerais.md`). `.app-card` inteiro é `draggable="true"` (não precisa de um handle separado como os widgets, já que não tem `resize` pra conflitar); o botão de remover (`.app-card__remove`) é `draggable="false"` pra não interferir.
`.app-card*` mora em `components.css` (junto de outra UI genérica reutilizável).

View File

@ -1,11 +0,0 @@
# Changelog — Favoritos
> Histórico específico desta aplicação, extraído de `plano.md`. Rodadas que mudaram mais de uma aplicação ao mesmo tempo (estrutura do portal, CSS transversal, modelo de permissões) continuam em `plano.md` — a numeração de rodada é a mesma usada lá, para referência cruzada.
### Rodada 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 `favoritos.md`) 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.
### Rodada 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.

View File

@ -1,13 +0,0 @@
# Favoritos
> Ver `docs/favoritos/favoritos.md` para o detalhamento técnico (como o `app_id` de uma aplicação é derivado, reordenação). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
Grade de aplicações favoritadas na tela Principal (`portal.html`). Qualquer aplicação do menu (item de topo ou sub-item dentro de um grupo) pode ser marcada como favorita — uma estrela ao lado do link — e os cards resultantes ficam reordenáveis por drag-and-drop na grade da tela Principal.
Não existe um cadastro próprio de "quais aplicações podem ser favoritadas": o identificador de cada favorito é derivado automaticamente da própria estrutura do menu (texto do link + seção pai), então uma aplicação nova, ao ser adicionada ao menu, já nasce favoritável sem nenhum código extra.
## Onde mexer
- `static/js/favorites.js` — coleta os itens favoritáveis do menu, injeta a estrela, desenha a grade de `portal.html` e trata o drag-and-drop.
- `Favorito` (`portal_api/models.py`) + `/api/favoritos/` (`portal_api/views.py`) — persistência por usuário.
- `.app-card*` em `components.css`.

View File

@ -4,7 +4,7 @@
## Links & Ferramentas
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com **duas** aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (`<li data-section="links-ferramentas"><a href="links-ferramentas.html">`), agora é um `nav-group` expansível (mesmo padrão de "Portais"/"Auditorias") com dois `nav-subitem`: "Links & Ferramentas" (`data-app="links-ferramentas-visualizar"`, mesma URL de antes) e "Acessos Gerais" (`data-app="acessos-gerais-visualizar"`, `acessos-gerais.html`). Isso teve um efeito colateral em `favorites.js`: como o `<li data-section="links-ferramentas">` não tem mais um `<a class="nav-item">` direto (virou um `<button data-group-toggle>`), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio `app_id` derivado (`"links-ferramentas__links-ferramentas"`/`"links-ferramentas__acessos-gerais"`, ver `docs/favoritos/favoritos.md`). Um favorito antigo com `app_id === "links-ferramentas"` (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em `docs/favoritos/favoritos.md`: mudar a forma como um item aparece no menu muda o `app_id` derivado.
"Links & Ferramentas" era uma seção do menu com uma única aplicação (a grade de cartões); virou uma seção com **duas** aplicações reais — a grade de cartões original e "Acessos Gerais" (ver seção própria abaixo) — quando essa segunda foi adicionada. Por isso o item do menu, que antes era um link direto (`<li data-section="links-ferramentas"><a href="links-ferramentas.html">`), agora é um `nav-group` expansível (mesmo padrão de "Portais"/"Auditorias") com dois `nav-subitem`: "Links & Ferramentas" (`data-app="links-ferramentas-visualizar"`, mesma URL de antes) e "Acessos Gerais" (`data-app="acessos-gerais-visualizar"`, `acessos-gerais.html`). Isso teve um efeito colateral em `favorites.js`: como o `<li data-section="links-ferramentas">` não tem mais um `<a class="nav-item">` direto (virou um `<button data-group-toggle>`), a seção como um todo deixou de ser favoritável — só os dois sub-itens são, cada um com seu próprio `app_id` derivado (`"links-ferramentas__links-ferramentas"`/`"links-ferramentas__acessos-gerais"`, ver `docs/favoritos.md`). Um favorito antigo com `app_id === "links-ferramentas"` (de antes dessa mudança) para de casar — mesma categoria de caveat já documentada em `docs/favoritos.md`: mudar a forma como um item aparece no menu muda o `app_id` derivado.
`LinkFerramenta` é uma lista **global/compartilhada** (sem FK pra `Usuario`, ao contrário de `Favorito`/`WidgetUsuario`/`NotificacaoDispensada`) — todo usuário com `apps["links-ferramentas-visualizar"]=True` em `permissoes["links-ferramentas"]` vê os mesmos cartões via `GET /api/links-ferramentas/` (ver "Padrão visualizar/editar", `CLAUDE.md` na raiz).
@ -16,8 +16,8 @@ Só quem tem `apps["links-ferramentas-editar"]=True` (`me.permissoes_efetivas["l
- **Ícone**: `icone` é um `ImageField` opcional (upload real, não URL) — exige Pillow (`requirements.txt`) e `MEDIA_URL`/`MEDIA_ROOT` (`settings.py`, servido em `DEBUG` por `config/urls.py`). Sem ícone, o cartão cai num SVG de fallback (`PID_LINK_DEFAULT_ICON` em `links-ferramentas.js`, mesmo glifo do item "Links & Ferramentas" no menu). O upload viaja como `multipart/form-data` (`FormData`), não JSON. Limite de tamanho: **2MB**, checado em dois lugares — `validar_tamanho_icone_link` (validator do campo `icone` em `models.py`, é a checagem que vale de verdade, roda via `LinkFerramentaSerializer.is_valid()`) e uma checagem espelhada em `links-ferramentas.js` (`PID_LINK_ICON_MAX_BYTES`, no `change` do input e de novo antes do POST/PATCH) só para dar feedback sem esperar a resposta do servidor. Ao mudar o limite, atualizar os dois lados (e gerar migração — `validators` no campo entra no `deconstruct()`).
- Clicar num cartão sempre abre a URL numa aba nova (`target="_blank"`) — são links externos por definição, não faz sentido navegar embutido no portal.
- Editar um cartão existente (nome, URL e ícone) usa o mesmo modal de "Adicionar Link" (`#lf-add-modal`), reaproveitado em modo edição — o botão de lápis em cada cartão (visível só com `apps["links-ferramentas-editar"]`, ao lado das setas de mover) chama `openModal(link)` pré-preenchendo os campos; salvar despacha `PATCH /api/links-ferramentas/{id}/` (`pidUpdateLink`, multipart igual ao POST) em vez de criar um novo. O campo de ícone fica sempre vazio ao abrir em modo edição (input `type="file"` não aceita valor pré-preenchido por segurança do browser) — não enviar o campo `icone` no PATCH mantém o ícone atual; só enviar substitui.
- **Favoritos por link** (`LinkFerramentaFavorito`, model dedicado — não confundir com `Favorito`, que marca aplicações inteiras do menu, ver `docs/favoritos/favoritos.md`): estrela em cada cartão (`.lf-card__favorite`, visível pra qualquer um com `apps["links-ferramentas-visualizar"]`, independente de editar) via `POST`/`DELETE /api/links-ferramentas-favoritos/{link_id}/` (natural key é o `id` do link, igual ao padrão `app_id`/`notif_id` de `Favorito`/`NotificacaoDispensada`). Só afeta a **ordem de exibição dentro da própria tela** — `links-ferramentas.js` busca `links` e favoritos em paralelo e reordena em memória (`sortFavoritesFirst()`) pra mostrar favoritos primeiro, preservando o `ordem` relativo dentro de cada grupo; o `ordem` compartilhado do `LinkFerramenta` nunca é tocado por favoritar/desfavoritar. Simplificação deliberada: as setas de mover e o drag-and-drop operam sobre esse mesmo array já reordenado (`links`), então se um usuário com `apps["links-ferramentas-editar"]` também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novo `ordem` compartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho.
- **Widget "Links Favoritos"** (`links-favoritos` em `PID_WIDGET_TYPES`, `static/js/widgets.js`, ver `docs/calendario-individual/calendario-individual.md` pro resto do sistema de widgets): lista em `portal.html` só os links favoritados, cada linha com ícone pequeno (`.widget-links-list__icon`, fallback `PID_WIDGET_LINK_DEFAULT_ICON` — cópia local do glifo de `PID_LINK_DEFAULT_ICON`, já que `widgets.css` não carrega `links-ferramentas.css`) + nome, a linha inteira é um `<a target="_blank">` pro mesmo destino do cartão original. Depende de `pidFetchLinks`/`pidFetchLinkFavoritos`, então `links-ferramentas.js` foi incluído em `portal.html` só por causa dessas funções de dados — seu handler de `DOMContentLoaded` retorna cedo lá (`if (!grid) return`, não existe `#lf-grid` em `portal.html`), mesmo padrão de guarda de `profiles.js`/`widgets.js`.
- **Favoritos por link** (`LinkFerramentaFavorito`, model dedicado — não confundir com `Favorito`, que marca aplicações inteiras do menu, ver `docs/favoritos.md`): estrela em cada cartão (`.lf-card__favorite`, visível pra qualquer um com `apps["links-ferramentas-visualizar"]`, independente de editar) via `POST`/`DELETE /api/links-ferramentas-favoritos/{link_id}/` (natural key é o `id` do link, igual ao padrão `app_id`/`notif_id` de `Favorito`/`NotificacaoDispensada`). Só afeta a **ordem de exibição dentro da própria tela** — `links-ferramentas.js` busca `links` e favoritos em paralelo e reordena em memória (`sortFavoritesFirst()`) pra mostrar favoritos primeiro, preservando o `ordem` relativo dentro de cada grupo; o `ordem` compartilhado do `LinkFerramenta` nunca é tocado por favoritar/desfavoritar. Simplificação deliberada: as setas de mover e o drag-and-drop operam sobre esse mesmo array já reordenado (`links`), então se um usuário com `apps["links-ferramentas-editar"]` também tiver favoritos marcados, arrastar um cartão persiste a ordem "favoritos primeiro" que ele está vendo como o novo `ordem` compartilhado — aceitável pro tamanho do app, mas vale lembrar se o comportamento parecer estranho.
- **Widget "Links Favoritos"** (`links-favoritos` em `PID_WIDGET_TYPES`, `static/js/widgets.js`, ver `docs/calendario-individual.md` pro resto do sistema de widgets): lista em `portal.html` só os links favoritados, cada linha com ícone pequeno (`.widget-links-list__icon`, fallback `PID_WIDGET_LINK_DEFAULT_ICON` — cópia local do glifo de `PID_LINK_DEFAULT_ICON`, já que `widgets.css` não carrega `links-ferramentas.css`) + nome, a linha inteira é um `<a target="_blank">` pro mesmo destino do cartão original. Depende de `pidFetchLinks`/`pidFetchLinkFavoritos`, então `links-ferramentas.js` foi incluído em `portal.html` só por causa dessas funções de dados — seu handler de `DOMContentLoaded` retorna cedo lá (`if (!grid) return`, não existe `#lf-grid` em `portal.html`), mesmo padrão de guarda de `profiles.js`/`widgets.js`.
## Acessos Gerais

View File

@ -1,43 +0,0 @@
# Changelog — Links & Ferramentas / Acessos Gerais
> Histórico específico desta aplicação, extraído de `plano.md`. Rodadas que mudaram mais de uma aplicação ao mesmo tempo (modelo de permissões visualizar/editar em si, ambiente de desenvolvimento) continuam em `plano.md` — a numeração de rodada é a mesma usada lá, para referência cruzada.
### Rodada 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:
1. **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=True` no seed), mas com o toggle já disponível em Perfis de Acesso pra liberar outros perfis depois, sem precisar de código novo.
2. **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.
3. 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`**: campo `gerencia_links_ferramentas` (BooleanField) em `PerfilAcesso`, união em `Usuario.gerencia_links_ferramentas()`, classe `PodeGerenciarLinksFerramentas` em `permissions.py`, exposta em `me.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 de `perfis-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_permissoes` em si só é setável via seed/admin, sem checkbox equivalente ainda; não alterado nesta rodada por não ter sido pedido). **Esse campo foi revertido na rodada 19** (ver abaixo) em favor do padrão visualizar/editar.
- **Model `LinkFerramenta`**: lista **global** (sem FK de usuário, ao contrário de Favorito/WidgetUsuario) — todo mundo vê os mesmos cartões. Campo `ordem` (inteiro) para a sequência de exibição; `icone` é `ImageField` opcional (exigiu adicionar Pillow ao `requirements.txt` e configurar `MEDIA_URL`/`MEDIA_ROOT`, que não existiam no projeto até agora — servidos em `DEBUG` por `config/urls.py`, sem equivalente em produção ainda configurado além do padrão whitenoise/nginx já usado pra `STATIC_ROOT`).
- **Reordenação sem endpoint de lote**: mover um cartão faz duas chamadas PATCH trocando o `ordem` de 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. **O drag-and-drop nativo foi adicionado depois** (ver `links-ferramentas-acessos-gerais.md`).
- **Upload multipart**: `pidApiRequest` (`api.js`) só sabia enviar JSON — estendido para detectar `body instanceof FormData` e, nesse caso, deixar o browser montar o `Content-Type: multipart/form-data` com 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 de `href="#"` pra `href="links-ferramentas.html"` nos 5 templates que replicam a sidebar.
### Rodada 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.
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.
### Rodada 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` da raiz → "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.
### Rodada 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 técnico completo em `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`, seção "Acessos Gerais".

View File

@ -1,16 +0,0 @@
# Links & Ferramentas / Acessos Gerais
> Ver `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md` para o detalhamento técnico. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
Duas aplicações reais dentro da mesma seção do menu:
- **Links & Ferramentas** (`links-ferramentas.html`): grade de cartões de atalho para ferramentas externas (Ottimizza, Sieg, GLPI, Trello, WhatsApp etc.), com ícone/imagem própria. Reordenar, incluir e remover cartões exige permissão de edição própria.
- **Acessos Gerais** (`acessos-gerais.html`): cadastro de logins/acessos compartilhados da equipe, organizado em seções e linhas, com popup de detalhes por linha (usuário, senha, observações com texto rico e imagens embutidas). Seções podem ser restritas a um subconjunto de perfis.
As duas seguem o mesmo padrão visualizar/editar (ver "Padrão visualizar/editar" no `CLAUDE.md` da raiz).
## Onde mexer
- `LinkFerramenta`, `LinkFerramentaFavorito`, `AcessoGeralSecao`, `AcessoGeral` (`portal_api/models.py`).
- `links-ferramentas.html`/`static/js/links-ferramentas.js`/`static/css/links-ferramentas.css`.
- `acessos-gerais.html`/`static/js/acessos-gerais.js`/`static/css/acessos-gerais.css`.

View File

@ -35,7 +35,7 @@ Na UI (`users-admin.js`/`usuarios.html`): coluna "Status" na lista (`.status-pil
**Filtro de status na lista** (`#ua-status-filtros`, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de `.ind-departamento-chip`): client-side, sobre o array `users` já carregado (`statusFiltro` em `users-admin.js`, aplicado em `renderList()` antes do filtro de busca por texto). Nasce em `"ativos"` por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra `"ativos"` só no load da página, não a cada `renderList()`.
**Colunas de código cadastral + ordenação** (`#ua-table`): a lista também mostra `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (`blank=True`) no model. Célula vazia renderiza `<span class="ua-campo-vazio">—</span>` (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo `<th data-sort="...">` (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (`sortKey`/`sortDir` em `users-admin.js`, ícone `↕` que fica `--accent` quando ativo) — mesmo padrão de `#ips-list-table` (`importacao-plano-saude.js`, ver `portal_api/planos_saude/CLAUDE.md`) e do modal de Ramais (`ramais-lookup.js`, ver `docs/ramais/ramais.md`), inclusive a mesma função de comparação (`comparaValoresUsuario`, número vs. número quando os dois convertem, senão `localeCompare` pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em `api.js`. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
**Colunas de código cadastral + ordenação** (`#ua-table`): a lista também mostra `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (`blank=True`) no model. Célula vazia renderiza `<span class="ua-campo-vazio">—</span>` (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo `<th data-sort="...">` (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (`sortKey`/`sortDir` em `users-admin.js`, ícone `↕` que fica `--accent` quando ativo) — mesmo padrão de `#ips-list-table` (`importacao-plano-saude.js`, ver `portal_api/planos_saude/CLAUDE.md`) e do modal de Ramais (`ramais-lookup.js`, ver `docs/ramais.md`), inclusive a mesma função de comparação (`comparaValoresUsuario`, número vs. número quando os dois convertem, senão `localeCompare` pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em `api.js`. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
**Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição**: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: `codigo_tareffa` via a view já existente em `portal_api/database/` que lê o Tareffa, ver [[project_database_package]] na memória) em vez de digitados à mão, mas essa vinculação de verdade **não foi implementada nesta rodada** — só a estrutura visual. Cada um dos 3 campos (`#ua-codigo-folha`/`#ua-codigo-questor`/`#ua-codigo-tareffa`) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (`.ua-field-link` — borda/raio únicos, botão separado por `border-left`, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre `disabled` com `title="Vinculação com sistema externo ainda não implementada"` — não tem nenhum handler de clique em `users-admin.js`. `codigo_contabit` e `ramal` (também campos cadastrais na mesma seção "Dados Cadastrais") **não** ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o `disabled`, adicionar o handler), não recriar o campo do zero.

View File

@ -1,85 +0,0 @@
# Changelog — Perfis de Acesso / Usuários
> Histórico específico desta aplicação, extraído de `plano.md`. Rodadas que mudaram o modelo de permissões transversal em si, ou o mecanismo de autenticação/menu de conta como um todo, continuam em `plano.md` — a numeração de rodada é a mesma usada lá, para referência cruzada.
### Rodada 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.
### Rodada 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.
### Rodada 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.
### Rodada 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".
### Rodada 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á). Esse menu de conta é a base do que, na rodada 33, viraria o modal "Gerenciar Usuário".
### Rodada 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, ver rodada 12), 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 — ver `docs/calendario-individual/calendario-individual.md`). 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 `docs/perfis-usuarios/perfis-usuarios.md`, seção "Liderança (gerente/coordenador) e o modal 'Gerenciar Usuário'".
### Rodada 65 — Filtro Ativos/Inativos/Todos na lista de Usuários
Usuário pediu um botão pra filtrar a lista de `usuarios.html` entre Ativos/Inativos, abrindo por padrão só com os Ativos, mas com opção de tirar o filtro pra ver os inativos também.
Implementado como 3 chips ("Ativos"/"Inativos"/"Todos", `#ua-status-filtros`, ao lado do título "Usuários") — mesma linguagem visual de `.ind-departamento-chip` (Indicador de Desempenho). Filtro 100% client-side sobre o array `users` já carregado (`statusFiltro`, padrão `"ativos"`), aplicado em `renderList()` antes da busca por texto. Sem endpoint novo.
### Rodada 66 — Colunas de código cadastral (Folha/Questor/Tareffa/Contabit/Ramal) + ordenação na lista de Usuários
Usuário pediu pra ver os campos cadastrais opcionais (`codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal`, hoje só visíveis dentro do formulário de edição) como colunas na lista, e poder ordenar por elas — mesmo sendo campos opcionais, o objetivo é achar quem está sem algum deles cadastrado antes de outras aplicações passarem a depender dessas informações.
`UsuarioListSerializer` já expunha os 5 campos (nenhuma mudança de backend necessária). Adicionadas 5 colunas em `#ua-table` (`usuarios.html`) entre Nome e Perfil de Acesso; célula vazia mostra `—` em itálico apagado (`.ua-campo-vazio`) em vez de ficar em branco, pra ficar visualmente óbvio ao ordenar. Cabeçalhos com `data-sort` (login, nome, os 4 códigos, ramal, status) ficam clicáveis e alternam asc/desc — mesmo padrão (`comparaValoresUsuario`, ícone `↕` que vira `--accent` no ativo) já usado em `#ips-list-table` (Importação de Plano de Saúde) e no modal de Ramais.
### Rodada 67 — Aba "Usuários do Escritório" (Perfis de Acesso) reestruturada em duas tabelas com seleção múltipla
Usuário anexou um print de outro sistema (duas grades lado a lado, cada uma com checkbox, busca por coluna, ordenação e um botão de ação em lote — "VINCULAR"/"DESVINCULAR") e pediu pra reestruturar a aba "Usuários do Escritório" (dentro da edição de um perfil, `perfis-acesso.html`) nesse mesmo estilo: ver todos os usuários ativos de um lado e vincular, ver os já vinculados do outro lado e desvincular.
Substituído o antigo `<select>` + botão "Vincular" + lista simples com X (`.pa-users-add`/`.pa-user-list`, removidos) por `.pa-users-dual` — duas tabelas (`#pa-users-available-*` à esquerda, só usuários ativos ainda não vinculados; `#pa-users-linked-*` à direita, todos os já vinculados, inclusive inativos, com selo `.status-pill--inativo`), cada uma com checkbox por linha + "selecionar todos" (sobre as linhas filtradas visíveis), busca por nome/e-mail, ordenação por nome (clique no cabeçalho, ícone `↕`) e um botão de atualizar. "Vincular"/"Desvincular" operam em lote sobre a seleção (`bulkAlterarVinculo()`, `Promise.all` de `PATCH /api/usuarios/{id}/` por usuário selecionado) — sem endpoint novo, só reaproveitando `GET/PATCH /api/usuarios/` que já existiam. Abrir a aba de um perfil diferente sempre reseta seleção/ordenação/busca dos dois painéis.
### Rodada 68 — Botão "Vincular" (só visual) nos campos Código da Folha/Questor/Tareffa
Usuário anexou um print mostrando 3 campos específicos (Código da Folha, Código do Questor, Código do Tareffa — não Contabit nem Ramal) e explicou que, no futuro, esses códigos serão buscados/vinculados automaticamente a partir de ferramentas externas (ex.: `codigo_tareffa` via a view que já existe no banco pra ler o Tareffa) em vez de digitados manualmente. Pediu explicitamente pra **não** construir essa vinculação agora — só deixar a estrutura visual pronta, com um botão "Vincular".
Passou por 3 versões visuais no mesmo dia até o formato final: (1) botão "Vincular" solto ao lado do input (`.btn-outline`, com texto); (2) só o ícone de elo no canto do próprio input (`position:absolute`, sem texto — pedido do usuário: "deixe o botão mais simples, só o símbolo no canto"); (3) formato final — input + botão "Vincular" (ícone + texto) encostados numa única caixa (`.ua-field-link`, borda/raio únicos, `border-left` separando os dois), depois do usuário mostrar um print de referência (campo de busca com botão "Buscar" atado à direita) e pedir esse mesmo estilo. Sempre `disabled` com `title` explicando que a vinculação ainda não foi implementada. Sem handler de JS nenhum — puramente estrutural, pra reaproveitar quando a integração de verdade for construída.
### Rodada 69 — Bug: `IntegrityError` ao criar um novo perfil de acesso (`codigo` duplicado)
Usuário reportou erro `500` ao criar um perfil pela tela: `django.db.utils.IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey" — DETAIL: Key (codigo)=(6) already exists`.
Causa: `seed_portal.py` semeia os 8 perfis com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, necessário porque vários lugares do código referenciam código fixo, ex. `codigo == 8`). No Postgres, `INSERT` com PK explícita não avança a sequence do `AutoField` — a sequence de `PerfilAcesso.codigo` ficou parada em 1 desde a criação do banco, então o primeiro `POST /api/perfis/` (sem PK explícita) pediu `nextval()` e recebeu um código baixo já usado pelo seed (6 = "Financeiro").
Corrigido com `_reset_sequence()` (nova função em `seed_portal.py`, `SELECT setval(pg_get_serial_sequence(...), MAX(codigo))` via SQL puro) chamada logo após semear `PERFIS_SEED` — toda execução do comando realinha a sequence de novo, então o bug não volta a acontecer mesmo que o seed seja reexecutado no futuro. Aplicado no banco em produção rodando `python manage.py seed_portal` (confirmado via `SELECT last_value FROM portal_api_perfilacesso_codigo_seq` = 8, igual ao maior código existente).
### Rodada 70 — "Usuários sob liderança" reestruturado em duas tabelas (não vinculados/vinculados)
Usuário anexou dois prints — um da própria seção "Liderança" (checklist único, com busca e "marcar todos") e outro de um sistema externo com duas grades lado a lado (checkbox, "Código"/"Nome", ordenação, paginação, VINCULAR/DESVINCULAR) — e pediu pra reestruturar "Usuários sob liderança" nesse mesmo estilo de duas tabelas: não vinculados de um lado, vinculados do outro (corrigido depois pra confirmar: não vinculados à **esquerda**, vinculados à **direita** — mesma convenção já usada no widget "Usuários do Escritório" de Perfis de Acesso, rodada 67).
Essa seção aparece em dois lugares (formulário de edição de usuário em `usuarios.html` **e** o modal "Gerenciar Usuário", presente em todo shell, pra autogestão do próprio gerente/coordenador) — perguntado ao usuário se a mudança deveria valer só pra tela de Usuários ou pros dois lugares; resposta: os dois.
Como o widget "Usuários do Escritório" (rodada 67) já tinha esse exato padrão visual mas vivia só em `perfis-acesso.css`/inline em `profiles.js` (só usado nessa página), foi extraída uma versão genérica reaproveitável: `static/js/dual-select.js` (`pidCriarSeletorDuplo()`, fábrica que recebe as referências de DOM de cada painel + uma função pro valor da coluna secundária, devolve `{setDados, getVinculadosIds}` — vinculação só em memória, sem chamada de API própria) + `.dual-select`/`.dual-select__*` em `components.css` (não em `perfis-acesso.css`, já que o modal "Gerenciar Usuário" existe em todo shell e a maioria não carrega esse CSS). `dual-select.js` foi adicionado ao prefixo de scripts compartilhado das 10 páginas-shell, logo depois de `api.js`.
Trocados: `usuarios.html` (seção "Liderança") e as 10 cópias do modal "Gerenciar Usuário" (uma por shell) — `.checklist-box`+busca+"marcar todos" virou o par de tabelas com coluna "Nome" (ordenável) + "Departamento" (busca própria). `#manage-account-modal-card` ganhou id novo pra `account.js` poder aplicar `.modal-card--wide` só quando `me.lideranca` é `true` (o modal padrão de 420px não cabe duas tabelas lado a lado). `users-admin.js`/`account.js` tiveram a lógica de checklist único substituída por chamadas a `pidCriarSeletorDuplo` — o restante do fluxo (salvar `liderados` junto do form em `usuarios.html`, `PATCH /api/me/liderados/` imediato no modal) não mudou.
**Ajuste de proporção no mesmo dia**: usuário reportou dois problemas visuais depois de testar — (1) "Perfis de Acesso" e "Departamento" (`usuarios.html`) não ficaram do mesmo tamanho; (2) o par de tabelas de "Usuários sob liderança" ficou comprimido num canto, sem usar a largura da seção. Causa de (1): `.pa-field-row.ua-permissions-row` usava `grid-template-columns: 1.6fr 1fr` (resquício de quando só existia um checklist de cada lado, sem se importar com simetria) — trocado para `1fr 1fr`. Causa de (2): `.ua-liderados-field` tinha `max-width: 480px`, herdado de quando o campo continha só um `.checklist-box` estreito — removido, já que agora o campo precisa da largura inteira da seção pras duas tabelas do `.dual-select`.
### Rodada 72 — Inativar usuário desvincula perfis de acesso e liderança automaticamente
Regra de negócio pedida pelo usuário: ao marcar um usuário como Inativo, ele deve ser desvinculado automaticamente de todos os perfis de acesso que tinha, e removido da lista de liderados de qualquer gerente que o tivesse — pra não deixar um vínculo "pendurado" em alguém que já não pode mais acessar o Portal.
Implementado em `_revogar_acesso_se_inativo()` (`serializers.py`), chamada ao final de `UsuarioSerializer.create()`/`update()` (depois dos `.set()` de `perfis`/`departamentos`/`liderados`, nunca antes — o formulário de edição sempre reenvia o checklist de perfis inteiro junto com qualquer alteração no checkbox "Usuário ativo", então limpar antes dos `.set()` seria desfeito na mesma requisição). Cobre os dois pontos reais de entrada (botão de alternar na lista e checkbox no formulário), ambos via `PATCH /api/usuarios/{id}/`.
Uma primeira versão tentou resolver isso em `Usuario.save()` (model), mas foi abandonada: tanto o `ModelSerializer.update()` do DRF quanto o admin do Django chamam `.set()`/`save_m2m()` nos M2M **depois** de `instance.save()`, então uma limpeza dentro de `save()` seria sempre desfeita pelo `.set(perfis)` que vem a seguir no mesmo request. Por isso a limpeza mora no serializer, como o último passo, não no model.
**Complemento no mesmo dia**: usuário reportou visualmente que usuários inativos ainda apareciam nos dois widgets de vinculação (dual-select de "Usuários sob liderança" e de "Usuários do Escritório" em Perfis de Acesso) — pediu que usuário inativo nunca seja exibido em nenhum dos dois painéis (nem disponível, nem já vinculado). Corrigido: `listaParaPainelUsuarios()` (`profiles.js`) agora filtra `usuariosCacheAtual` por `is_active` antes de separar entre disponível/vinculado (removido também o selo "Inativo" que antes marcava um usuário inativo ainda vinculado, já que esse caso não deve mais aparecer); `fillLideradosChecklist()` (`users-admin.js`) ganhou o mesmo filtro. No modal "Gerenciar Usuário" (`account.js`) já não era um problema — `GET /api/usuarios-resumo/` só devolve usuários ativos.
### Rodada 81 (parte) — Correção do `seed_portal.py` resetando nomes de perfil
Ver `plano.md` (rodada 81) para o pedido completo de "Mais informações" por aplicação — mecanismo genérico e transversal. A parte específica desta aplicação: o usuário tinha renomeado o perfil "Integração e Inovação" (código 8) pra "Inovação" e criado um perfil novo "Integração" (código 9) — mas `seed_portal.py` fazia `update_or_create(codigo=..., defaults={"nome": ...})`, que reescrevia `nome` de volta pro valor original a cada execução do seed. Corrigido pra `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação); `ativo`/`gerencia_permissoes`/`permissoes` continuam realinhados a cada execução, de propósito. Todas as 8 telas/textos do frontend que ainda diziam "Integração e Inovação" foram atualizadas pra "Inovação".

View File

@ -1,18 +0,0 @@
# Perfis de Acesso / Usuários
> Ver `docs/perfis-usuarios/perfis-usuarios.md` para o detalhamento técnico das telas administrativas (popup "Nova Aplicação", aba "Usuários do Escritório", inativação, Liderança). Ver `CLAUDE.md` na raiz para o **modelo de permissões em si** (formato do JSON de `PerfilAcesso.permissoes`, padrão visualizar/editar, `permissoes_efetivas()`) — esse conteúdo é transversal e vive lá porque toda aplicação do Portal depende dele.
Duas telas administrativas, restritas a quem tem `gerencia_permissoes`:
- **Perfis de Acesso** (`perfis-acesso.html`): CRUD de perfis, com a árvore de permissões por módulo/aplicação e a aba "Usuários do Escritório" (vínculo em massa entre perfil e usuários).
- **Usuários** (`usuarios.html`): CRUD de contas, incluindo dados cadastrais opcionais, filtro por status (ativo/inativo), inativação (que desvincula perfis e liderança automaticamente) e a seção "Liderança" (gerente/coordenador → liderados).
Inclui também o modal "Gerenciar Usuário" (disponível em todo shell), onde qualquer usuário troca a própria senha e um gerente/coordenador autogerencia seus próprios liderados sem precisar de acesso à tela administrativa.
## Onde mexer
- `Usuario`, `PerfilAcesso`, `Departamento` (`portal_api/models.py`).
- `perfis-acesso.html`/`static/js/profiles.js`/`static/css/perfis-acesso.css`.
- `usuarios.html`/`static/js/users-admin.js`.
- `static/js/account.js` — modal "Gerenciar Usuário" (presente em todo shell).
- `static/js/dual-select.js` — componente de duas tabelas (não vinculados/vinculados) reaproveitado nos dois vínculos (usuários↔perfil, gerente↔liderados).

View File

@ -1,101 +0,0 @@
# Changelog — Ramais
> Histórico específico desta aplicação, extraído de `plano.md`. Rodadas que mudaram mais de uma aplicação ao mesmo tempo (favicon, tema, texto do menu) continuam em `plano.md` — a numeração de rodada é a mesma usada lá, para referência cruzada.
### Rodada 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 `docs/ramais/ramais.md`):
- Dois models novos: `Ramal` (linha da tela, opcionalmente vinculada a `Usuario` — vinculada, os dados vêm sempre do cadastro; avulsa, `nome`/`departamento`/`numero` moram na própria linha) e `RamalAusencia` (`esta_ativa()` calcula "ausente agora" comparando datas/horas, sem armazenar um flag; `encerrada_manualmente` permite encerrar antes do previsto sem mexer no período original).
- Mesmo padrão visualizar/editar da rodada 19 (Links & Ferramentas): `ramais` ganhou os dois apps na árvore de permissões, com o mesmo cuidado no `seed_portal.py` de forçar `editar=False` pra todo perfil que não seja Integração e Inovação (já que `ramais` está em `BASE_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 a `gerencia_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*` de `perfis-acesso.css` (mesmo padrão que `usuarios.html` já usa sem CSS de tabela próprio). Nos outros 5 templates, o item de menu "Ramais" (antes `href="#"`) e o botão "Acessar Ramais" do topbar (antes um `<button>` sem handler nenhum) passaram a apontar de verdade pra `ramais.html`.
- Migração `0012_ramal_ramalausencia` gerada e aplicada no ambiente local; `seed_portal` reaplicado (confirmado via shell: 7 perfis com `ramais.apps.editar=False`, só "Integração e Inovação" com `True`).
### Rodada 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:
1. "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 `Ramal` vinculada a um `Usuario` pra 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.
2. "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:
- **`Ramal` perdeu o campo `usuario`** (migração `0013_remove_ramal_usuario_alter_ramal_nome`) — agora é só linha avulsa (sem conta de sistema por trás, ex.: telefone de sala), com `nome` obrigatório e `departamento`/`numero` opcionais (podem ficar pendentes, igual a um colaborador de verdade).
- **`RamalViewSet.list()` foi reescrito** pra mesclar duas fontes numa lista só, sem passar pelo `RamalSerializer`: todo `Usuario` ativo (linha montada direto do cadastro — `nome`, `departamentos`, `ramal`) + as linhas avulsas de `Ramal`. Cada item ganha um `id` sintético (`"usuario-<id>"`/`"avulso-<id>"`) e um `tipo`, 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 em `Usuario.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>` (via `GET /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()` filtra `is_active=True`) — efeito colateral da reescrita que corrige uma limitação que a rodada 21 tinha deixado em aberto.
- **Select/textarea estilizados**: `components.css` ganhou `.modal-field select`/`.modal-field textarea` genéricos (seta customizada via `background-image`, sem o `appearance` nativo 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.
### Rodada 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:
1. **Overflow no modal de Adicionar Ramal**: `.modal-field-row` usava `grid-template-columns: 1fr 1fr` sem `minmax(0, ...)`, e os inputs de `.modal-field` não tinham `width: 100%` explícito — o conteúdo (tamanho intrínseco do `<input>`) empurrava a grade além da borda do modal. Corrigido em `components.css` (`grid-template-columns: minmax(0, 1fr) minmax(0, 1fr)` + `width: 100%` em `input`/`select`/`textarea` de `.modal-field`) — bug genérico, vale pra qualquer modal do portal com campos lado a lado, não só Ramais.
2. **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 em `ramais.css`: `.ram-chamado-card` passou 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 virou `flex:1` dentro do `.modal-card` (que já é flex-column), preenchendo todo o espaço vertical restante.
3. **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 `RamalAusenciaViewSet` que já existia (nenhuma mudança de backend — `GET`/`PATCH`/`DELETE` em `/api/ramais-ausencias/{id}/` já funcionavam, só faltava a UI): o badge virou um `<button data-ram-ver-ausencia>` que busca o registro via `GET` e 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ó marcava `encerrada_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).
### Rodada 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.
### Rodada 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-btn` voltou 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 tela `ramais.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, reaproveitando `GET /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 tiver `apps.visualizar` em `ramais` — 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-table` de `perfis-acesso.css` porque `portal.html`/`links-ferramentas.html`/`calendario-individual.html` não carregam esse CSS (só `ramais.html`/`usuarios.html`/`perfis-acesso.html` carregam) — `ramais-lookup.css` ficou 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).
### Rodada 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 de `Ramal` avulso): `TelefoneExterno` (nome/ramal/telefone/observações, só `nome` obrigatório) e `FuncaoTelefonia` (comando/função/resumo, só `comando` obrigató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 campo `ordem` manual e endpoint de reorder.
- **Permissão reaproveitada**: as duas subtelas usam o mesmo par `ramais.apps.visualizar`/`ramais.apps.editar` que já existia (`PermissaoApp("ramais", app_key)`) — são subtelas da mesma seção do menu, não aplicações novas; nenhuma mudança em `catalogo.py`.
- **Seed só para Funções de Telefonia**: `seed_portal.py` ganhou `FUNCOES_TELEFONIA_SEED` (as 13 linhas do print, via `update_or_create` por `comando`, 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-panel` que já existia em `perfis-acesso.css` (mesmo padrão das abas Permissões/Usuários de `perfis-acesso.html`), com uma implementação independente em `ramais.js` (`data-ram-tab`/`data-ram-tab-panel`/`activeRamTab`) pra não colidir com `profiles.js`. O conteúdo que já existia (filtros/tabela/botões de Ramais) migrou pro painel `data-ram-tab-panel="ramais"`, sem mudar de comportamento.
- Endpoints novos: `/api/telefones-externos/` e `/api/funcoes-telefonia/`, CRUD padrão via `ModelViewSet`.
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.
### Rodada 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 `ModelViewSet` relacionados (`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"` — `RamalAusenciaViewSet` usa as mesmas chaves de `ramais-visualizar`/`ramais-editar` do diretório, por ser parte dessa mesma aba.
- **`seed_portal.py`**: o override que força `editar=False` pros 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 único `canView`/`canManage` pra 5 pares (um por aba), um objeto `ramTabViewPerms` que esconde (`hidden`) o botão de cada aba sem `visualizar`, 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 de `apps.visualizar` genérico pra `apps["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).

View File

@ -1,15 +0,0 @@
# Ramais
> Ver `docs/ramais/ramais.md` para o detalhamento técnico. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
Diretório de ramais internos (`ramais.html`). A listagem é **automática**: todo colaborador ativo já aparece sozinho (montado a partir do cadastro de Usuários), com o ramal pendente até ser preenchido; linhas avulsas (telefone de sala, recepção etc.) são cadastradas à parte. Mostra também colaborador ausente e aniversariante do dia.
A tela é uma seção com 5 subtelas por abas: **Ramais** (diretório), **Responsável no Tareffa** (placeholder), **Telefones Externos**, **Férias** (placeholder) e **Funções de Telefonia** — cada aba com sua própria permissão de visualização, e as 3 administráveis (Ramais, Telefones Externos, Funções de Telefonia) com permissão de edição própria.
Um modal de consulta rápida (botão "Ramais" no topbar de `portal.html`/`links-ferramentas.html`/`calendario-individual.html`) mostra o mesmo diretório sem precisar navegar até a tela completa.
## Onde mexer
- `Ramal`, `RamalAusencia`, `TelefoneExterno`, `FuncaoTelefonia` (`portal_api/models.py`).
- `ramais.html`/`static/js/ramais.js`/`static/css/ramais.css`.
- `static/js/ramais-lookup.js`/`static/css/ramais-lookup.css` — modal de consulta rápida.

View File

@ -4,4 +4,4 @@
Os 6 tópicos do menu "Solicitações" (`catalogo.MODULE_APPS["solicitacoes"]`) não são telas próprias — cada um (exceto "Ordem de Serviço", que ainda não tem link definido e continua com `href="#"`, mesmo padrão de qualquer aplicação-placeholder do portal) é só um link externo (hoje, um formulário do Asana) aberto em **nova aba** (`target="_blank" rel="noopener noreferrer"`), igual ao padrão já usado nos cartões de Links & Ferramentas.
**Por que não embutido em iframe**: a primeira versão desta seção tentava centralizar os 5 links num popup com `<iframe>` (numa página dedicada `solicitacoes.html`), inspirado no "Novo Chamado" de Ramais. Revertido no mesmo dia: o Asana bloqueia ser carregado em iframe de outro domínio via `X-Frame-Options`/`Content-Security-Policy: frame-ancestors` (proteção padrão contra clickjacking), então o navegador recusa a conexão (`net::ERR_BLOCKED_BY_RESPONSE`/"A conexão com form.asana.com foi recusada"). Isso não tem workaround no frontend — não confundir com o iframe de "Novo Chamado" em Ramais (`docs/ramais/ramais.md`) ou o de "Calendário De Paula" (ver `CLAUDE.md` na raiz, seção "Páginas"), que funcionam porque aquela outra ferramenta (`depaula-tvcorporativa.lovable.app`) não bloqueia embed. Não reintroduzir esse padrão de iframe pra Solicitações sem confirmar com o usuário que o destino realmente permite ser embutido.
**Por que não embutido em iframe**: a primeira versão desta seção tentava centralizar os 5 links num popup com `<iframe>` (numa página dedicada `solicitacoes.html`), inspirado no "Novo Chamado" de Ramais. Revertido no mesmo dia: o Asana bloqueia ser carregado em iframe de outro domínio via `X-Frame-Options`/`Content-Security-Policy: frame-ancestors` (proteção padrão contra clickjacking), então o navegador recusa a conexão (`net::ERR_BLOCKED_BY_RESPONSE`/"A conexão com form.asana.com foi recusada"). Isso não tem workaround no frontend — não confundir com o iframe de "Novo Chamado" em Ramais (`docs/ramais.md`) ou o de "Calendário De Paula" (ver `CLAUDE.md` na raiz, seção "Páginas"), que funcionam porque aquela outra ferramenta (`depaula-tvcorporativa.lovable.app`) não bloqueia embed. Não reintroduzir esse padrão de iframe pra Solicitações sem confirmar com o usuário que o destino realmente permite ser embutido.

View File

@ -1,5 +0,0 @@
# Changelog — Solicitações
> Histórico específico desta aplicação, extraído de `plano.md`.
Nenhuma rodada dedicada foi registrada em `plano.md` para esta seção — os links foram cadastrados/ajustados ao longo de outras rodadas sem um pedido próprio que justificasse uma entrada de changelog. A única decisão de arquitetura relevante (por que os links não podem ser embutidos em iframe) está documentada em `docs/solicitacoes/solicitacoes.md`.

View File

@ -1,9 +0,0 @@
# Solicitações
> Ver `docs/solicitacoes/solicitacoes.md` para o detalhamento técnico. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
Os itens do menu "Solicitações" não são telas próprias dentro do Portal — cada um é um link externo (hoje, um formulário do Asana) aberto em nova aba. Não é possível embutir em iframe: o Asana bloqueia esse tipo de incorporação via `X-Frame-Options`/CSP.
## Onde mexer
`catalogo.MODULE_APPS["solicitacoes"]` (`portal_api/catalogo.py`) — cadastro dos 6 tópicos do menu e seus links.

922
plano.md

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
# Changelog — Simulação de Custo de Contratação
> Histórico específico desta aplicação, extraído de `plano.md` (mesma numeração de rodada usada lá, para referência cruzada).
### Rodada 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 técnico completo no `CLAUDE.md` desta pasta.

View File

@ -1,16 +0,0 @@
# Simulação de Custo de Contratação (Geradoc)
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (models, fórmula, tabelas fiscais). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
Substitui a planilha manual de custo de contratação (`projects/planilha de custo/*.xlsx`) por um formulário no Portal: o usuário preenche os dados do empregado, a ferramenta calcula o custo total de contratação (salário, encargos, INSS, IRRF) e devolve um PDF pronto para enviar ao cliente.
**Escopo atual: só Empregado CLT.** O pedido original previa outras 4 modalidades (Simples Nacional, Regime Normal, Pró-labore, Empregado Doméstico), mas elas dependem de uma planilha/regra de referência validada pelo contador que ainda não existe.
As tabelas de INSS/IRRF e os parâmetros da redução de IRRF da Lei 15.270/2025 são editáveis pela própria tela (painel colapsável), não hardcoded — mudam por lei/todo ano.
## Onde mexer
- `portal_api/custo_contratacao/` — `tabelas.py` (seed/default das faixas fiscais), `calculo.py` (`ParametrosFiscais` + `calcula_custo_empregado`), `pdf.py` (`gera_pdf_simulacao`, via `reportlab`).
- `ParametroFiscalCustoContratacao` (`portal_api/models.py`) — singleton com as tabelas fiscais atuais.
- `custo-contratacao.html` / `static/js/custo-contratacao.js` (se existir) / `static/css/custo-contratacao.css`.
- Skill `simulacao-custo-contratacao` (`.claude/skills/`) — contexto de negócio, limitações conhecidas.

View File

@ -1,125 +0,0 @@
# Changelog — Indicador de Desempenho
> Histórico específico desta aplicação, extraído de `plano.md` (mesma numeração de rodada usada lá, para referência cruzada).
### Rodada 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` desta pasta.
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` da raiz 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.
### Rodada 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.
### Rodada 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.
### Rodada 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` desta pasta → "Departamento organizacional" pro detalhe técnico completo — mecanismo depois **substituído por completo** na rodada 45):
- Campo novo `IndicadorApuracaoColaborador.setor` (migração `0032`) — valor bruto da coluna "departamento" da planilha, lido em `leiaute.le_servicos_tareffa` (campo novo `LinhaTareffa.setor`) e propagado por `pipeline.processa_apuracao` até o colaborador (primeira ocorrência por responsável, mesmo padrão já usado pra `gerente`).
- 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 ler `IndicadorSetorApelido`): `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_departamento` deixou de ser ajustado/recalculado pra **toda** a apuração de uma vez — `IndicadorApuracaoViewSet.ajustar_departamento`/`recalcular_departamento` agora recebem `setor` no corpo e aplicam só aos colaboradores daquele setor (`setores.colaboradores_do_setor`), mesma mecânica que `ajustar_grupo`/`recalcular_grupo` já 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 &lt;setor&gt;)" + as linhas de Grupo só dos gerentes daquele setor) quanto a lista de colaboradores abaixo. Campo `setor_bucket` (`SerializerMethodField`, já resolvido no servidor) exposto por colaborador.
- **Apurações criadas antes desta mudança** ficam com `setor` em branco — feito um backfill pontual, via `manage.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.
### Rodada 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:
1. **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-popover` em `indicador-desempenho.css`), inspirado no mesmo mecanismo de `.notif-dropdown` (`notifications.js`): botão "i" (`data-ind-info-toggle`) alterna um `<span>` posicionado em `absolute` logo abaixo, e um listener em `document` fecha ao clicar fora. Como o botão fica dentro de um `<th>` (que tem `text-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 em `renderHonorarioAjustadoInfoHtml(colaborador)`, chamada por colaborador ao montar a tabela — sem mudança de backend, `pct_individual` já vinha no payload.
2. **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, via `pidIndAgruparPorSetor`) 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 sobre `colaborador.setor_bucket`, já exposto desde a rodada 41).
3. **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 duplicar `loteGlobal*`), 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.gerar` passou a aceitar `colaborador_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ó é marcada `concluida` quando 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 via `django.test.Client` logado como `gabriel`: geração parcial (1 colaborador) manteve `status="revisao"`; geração de todos marcou `concluida`; um id de outra apuração devolveu 400. O estado real da apuração no ambiente local (já estava `concluida` de um uso anterior) foi restaurado ao original depois do teste.
### Rodada 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):
1. **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_individual` passa 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`) que `recalcula_colaborador` teria gravado em `pct_individual` se não houvesse ajuste manual, calculada ali mesmo a partir dos níveis que a função já monta. `recibo.py._banner_percentual` passou a mostrar `composicao["total_calculado"]` no banner principal (sempre o medido) e, só quando `pct_individual_ajustado_manualmente`, uma linha extra com o rótulo novo + `colaborador.pct_individual` (o valor pago).
2. **"Total Resultado" → "Total do Indicador"**: troca simples de rótulo na última linha da tabela "Empresas" (`_tabela_empresas`).
3. **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 com `ALIGN` à 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 em `Paragraph` com 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.
### Rodada 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:
1. **"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 `&nbsp;` (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`/`RIGHTPADDING` da 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.
2. **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.
### Rodada 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` desta pasta → "Departamento organizacional" pro detalhe técnico completo):
- Dois models novos: `IndicadorDepartamento` (nome/ativo) e `IndicadorDepartamentoGerente` (gerente→departamento, `nome_gerente` único — um gerente só pertence a um departamento, mas um departamento aceita vários gerentes).
- `IndicadorCriterio`/`IndicadorPercentualTipo` ganharam FK obrigatória `departamento` — 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) virou `departamento` (FK nullable) — resolvido uma vez na criação da apuração, a partir do `gerente` do colaborador via `IndicadorDepartamentoGerente` (não mais da coluna bruta da planilha). Sem mapeamento pro gerente, o colaborador fica com `departamento=None` e 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 → `RunPython` criando "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.py` ajustado 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` desta pasta**: 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. **Continua em aberto** (ver `plano.md`, "Roadmap").
### Rodada 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.

View File

@ -1,16 +0,0 @@
# Indicador de Desempenho (Geradoc)
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (models, fórmula de composição, departamento organizacional, layout do PDF). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também a skill `indicador-desempenho` (`.claude/skills/`) para contexto de negócio.
Segunda ferramenta de Geradoc — substitui a apuração manual do indicador de desempenho do Fiscontábil (departamentos Contábil/Fiscal), antes feita numa planilha (`.ods`) com fórmulas quebradas por edições manuais acumuladas. A partir de duas planilhas do Tareffa (Serviços/Honorários), calcula o percentual de indicador de cada colaborador (composição ponderada de critérios Individual/Grupo/Departamento) e gera um recibo em PDF por colaborador.
**Escopo atual: só o Fiscontábil** (papéis Balancete/Liberação Fiscal/Conciliação Financeira) — outros departamentos ficam para rodada futura, mas a estrutura de cadastro (departamentos, critérios, percentuais) já suporta múltiplos departamentos com regras próprias.
Critérios e percentuais são cadastros editáveis pela própria tela (histórico com `vigente_desde`, nunca editados in-place), não hardcoded. Recibo é documento interno do RH — sem visão própria do colaborador no Portal nesta versão.
## Onde mexer
- `portal_api/indicadores/` — `tipos.py` (tipo de colaborador por empresa), `leiaute.py` (leitura das planilhas Tareffa via `openpyxl`), `pipeline.py` (`processa_apuracao`), `entregas.py` (3 critérios automáticos), `calculo.py` (composição dos percentuais), `recibo.py` (PDF via `reportlab`), `departamentos.py`.
- `IndicadorDepartamento`, `IndicadorDepartamentoGerente`, `IndicadorCriterio`, `IndicadorPercentualTipo`, `IndicadorApuracao`, `IndicadorApuracaoColaborador`, `IndicadorApuracaoEmpresa`, `IndicadorApuracaoResposta` (`portal_api/models.py`).
- `indicador-desempenho.html`/`static/js/indicador-desempenho.js`/`static/css/indicador-desempenho.css`.
- `management/commands/seed_indicador_desempenho.py` — popula o primeiro histórico com os valores da planilha antiga.

View File

@ -1,242 +0,0 @@
# Changelog — Importação de Plano de Saúde
> Histórico específico desta aplicação, extraído de `plano.md` (mesma numeração de rodada usada lá, para referência cruzada). É a aplicação com mais rodadas do projeto — cada operadora nova, cada regra de custeio e cada bug de parser tem sua própria entrada abaixo.
### Rodada 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` desta pasta.
### Rodada 47 — Bug: `SuspiciousFileOperation` ao anexar arquivo com nome muito longo
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.
### Rodada 48 — Banco de regras de custeio salvas
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ção `0037_regracusteioplanosaude`) — `nome`/`operadora`/`tipos_lancamento`/`custeio_por_tipo` (mesmo formato dos campos homônimos de `ImportacaoPlanoSaude`) + `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 de `IndicadorPercentualTipoViewSet` (`perform_create` grava `criado_por`).
- **Validação de custeio extraída pra uma função compartilhada** (`_monta_regra_custeio()`, `serializers.py`) — antes só existia dentro de `ImportacaoPlanoSaudeCreateSerializer._valida_regra_especifica()`; extraída pra módulo-level e reaproveitada por `RegraCusteioPlanoSaudeSerializer.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. `ImportacaoPlanoSaudeCreateSerializer` foi 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 `GET` com `limite_valor`/`percentual` já como `float` (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 (via `formata_valor_br`, já existente em `leiaute_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 por `parse_valor_br`, que remove pontos como separador de milhar). Validado via shell: criar uma regra, pegar `validated_data` de 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-modal` pra 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_por` grava 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`) virou `border-bottom` (era `border-top`), já que agora separa do campo abaixo (Operadora), não de cima.
### Rodada 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 em `pipeline.OPERADORAS` como `dental_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 `pdfplumber` de verdade contra o arquivo binário (não ficou salvo em nenhum lugar acessível pelo ambiente de desenvolvimento). A indentação exata que o `pdfplumber` com `layout=True` vai 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_pdf` da Unimed é só um `NotImplementedError` explícito por esse motivo).
### Rodada 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):
1. **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 que `pidErrorMessageFrom()` usa quando a resposta não é JSON, ver `api.js`). Causa: ao inserir `RegraCusteioPlanoSaudeSerializer` logo depois de `ImportacaoPlanoSaudeDetailSerializer` na rodada 48, o método `get_resumo_por_tipo()` (que já existia, definido **depois** do `class Meta` da 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.
2. **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 `pdfplumber` de verdade) era que o "[Nº Cartão]" e os valores apareciam *depois* de todas as linhas do nome; o comportamento real do `pdfplumber` é 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.
### Rodada 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.
### Rodada 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 em `pipeline.OPERADORAS` como `unimed_oeste_pr_saude`/"Unimed Oeste do Paraná" — **deliberadamente separado** do `unimed_saude` já 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 `pdfplumber` de verdade contra o PDF binário — validar com o botão "Selecionar arquivo" antes de confiar em produção.
### Rodada 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").
### Rodada 55 — Regra de custeio salva: "Salvar regra atual..." movido pro final do formulário + botão "Limpar seleção"
Ajuste de usabilidade pedido pelo usuário na seção "Regra de custeio salva" do formulário de Nova Importação: "Salvar regra atual..." saiu de junto de Aplicar/Ver regras salvas (topo do formulário) e passou pro **final** (depois de "Tipo de importação", antes do botão "Processar") — salvar só faz sentido depois de parametrizar o custeio, é o último passo do fluxo, não um botão que deveria ficar ao lado de Aplicar. Aplicar/Limpar seleção/Ver regras salvas continuam no topo, já que aplicar uma regra continua sendo o primeiro passo natural (preenche operadora + custeio de uma vez).
Novo botão "Limpar seleção" (`#ips-regra-limpar-btn`, ao lado de Aplicar) resolve o caso de aplicar a regra errada por engano: desfaz tanto o rastreamento (`regraSelecionadaId`/`regraAplicadaId`, texto do combobox, observações) quanto o próprio custeio que a regra preencheu (tipos de lançamento, radios de custeio, limite/percentual de cada combinação tipo×pessoa) — as duas partes de `resetForm()` que faziam isso foram extraídas em `limparCusteioForm()`/`limparRegraSelecionada()` pra serem reaproveitadas aqui. De propósito não mexe em Operadora nem nos arquivos já anexados — só desfaz o que uma regra aplicada de fato preenche em massa.
### Rodada 56 — Aba "Alterações" com histórico e reversão
Pedido do usuário: na tela de revisão (Mensalidade/Coparticipação/Auditoria), incluir uma quarta aba "Alterações" que registra cada edição de valor, exclusão de linha e inclusão manual de linha feita na revisão, permitindo verificar e reverter cada uma.
Novo model `ImportacaoPlanoSaudeAlteracao` (migração `0038`) — um registro por operação, nunca apagado (`revertida`/`revertida_em` marcam quando o usuário desfez). `ImportacaoPlanoSaudeLinhaViewSet` passou a gravar um registro a cada `perform_create`/`perform_update`/`perform_destroy`, com um snapshot completo da linha (`dados_linha`, JSONField) — necessário porque uma linha excluída deixa de existir, então o snapshot é o único jeito de mostrar/recriar essa linha depois. Novo endpoint `POST /api/importacoes-plano-saude-alteracoes/{id}/reverter/` desfaz uma alteração específica: edição volta o campo, inclusão remove a linha, exclusão recria a linha a partir do snapshot — idempotente, e a própria reversão não gera um novo registro (evita loop).
De propósito, o valor lançado por "Vincular pessoa" (resolução manual de auditoria) não entra nessa aba — já tem seu próprio rastro (selo "Resolvido" na aba Auditoria).
### Rodada 57 — Bug no parser da Itamed: reajuste de mensalidade não estava sendo somado ao valor lançado
Usuário reportou, testando o arquivo real de 08/2026: para "Elizangela de Paula Kuhn", a aplicação lançou R$ 900,65 de mensalidade, quando o correto era R$ 964,87 (a soma que a própria linha-resumo da pessoa mostra em "VI.Pré-Estab."/"VI mensal.").
Causa: na mini-tabela "Item/Valor" de cada pessoa, quando há reajuste no mês, o valor vem **separado** em duas linhas — "Reajuste - Variação de custo" (ex.: 64,22) e "Preço pré-estabelecido" (ex.: 900,65), que juntas somam o valor real da mensalidade (964,87). `operadoras/itamed/saude.py` só tinha regex pra "Preço pré-estabelecido"/"Co-participação" — a linha de reajuste não casava com nada e era simplesmente ignorada, subtraindo o valor do reajuste da mensalidade de **todo mundo, todo mês com reajuste** (não era um caso raro: no arquivo de teste, as 14 pessoas tinham essa linha).
Corrigido adicionando `_REAJUSTE_RE` e lançando "Reajuste - Variação de custo" também como `tipo_lancamento="mensalidade"` — soma automaticamente com "Preço pré-estabelecido" na agregação por indivíduo (`_agrega_por_individuo_e_tipo`, sem mudança nenhuma nela). Validado rodando o parser direto contra `media/planos_saude/operadora/501_Itamed.PDF`: os 3 totais da página de resumo (14 beneficiários, R$ 4.475,41 de mensalidade, R$ 600,63 de coparticipação) bateram exatamente depois da correção.
### Rodada 58 — Operadora ganha código de cadastro no nome + combobox pesquisável
Pedido do usuário: no `<select>` "Operadora" da tela de Nova Importação, incluir o código de cadastro de cada operadora no Questor antes do nome, e permitir buscar por nome (a lista tende a crescer, mesmo motivo que já tinha levado "Regra de custeio salva" a virar combobox na rodada 53).
`pipeline.OPERADORAS` — `label` de cada operadora passou a vir pré-formatado como `"<código> - <Nome>"`: `1723 - Dental Uni Odonto`, `3755 - Itamed Saúde`, `3758 - Amil Odonto`, `4709 - Unimed Oeste do Paraná`, `5060 - Unimed Saúde` (códigos informados pelo usuário). Como `GET /operadoras/` é a fonte única do rótulo em todo lugar que exibe operadora (combobox do formulário, `operadoraLabel()` na lista de regras salvas, `nome_operadora` de cada importação), a mudança aparece em todos esses lugares sem precisar tocar em nenhum deles.
O `<select id="ips-form-operadora">` virou um combobox pesquisável (`#ips-operadora-combo`), mesmo componente visual de "Regra de custeio salva" — as classes CSS que antes eram `.ips-regra-combo*` foram generalizadas pra `.ips-combo*` (`importacao-plano-saude.css`) pra serem reaproveitadas pelos dois campos, sem duplicar estilo. Diferença de implementação: Operadora não tem um botão "Aplicar" separado — o valor de fato submetido viaja num `<input type="hidden" id="ips-form-operadora">`, e escolher um item da lista já grava o valor na hora (`selecionarOperadora()`), preservando todo o código existente que lia `formOperadora.value` (validação do form, payload de submit, payload de "Salvar regra atual...", pré-validação de arquivo por operadora).
Ajuste pedido na sequência: "Limpar seleção" (da regra de custeio) passou a chamar também `limparOperadoraSelecionada()`, não só `limparRegraSelecionada()`/`limparCusteioForm()` — já que aplicar uma regra pode ter preenchido a Operadora junto, desfazer a seleção da regra precisa desfazer isso também.
### Rodada 59 — "Regra empresa" — custeio especial de mensalidade por família (primeira: Tecnomyl/Unimed)
Pedido do usuário: incluir uma empresa (Tecnomyl, cliente na Unimed, código 1778) cuja regra de custeio não cabe no desenho normal "por tipo de lançamento × titular/dependente" — a Tecnomyl paga até R$ 661,61 de mensalidade **por família inteira** (titular + todos os dependentes juntos, não por pessoa): família acima do teto tem o excedente descontado do empregado; igual ou abaixo, a empresa cobre 100%. O usuário deixou claro que regras assim serão sempre cadastradas diretamente por programação (nunca pela tela) e não têm relação nenhuma com o banco de "Regras de custeio salvas" (`RegraCusteioPlanoSaude`) já existente.
Terceiro checkbox em "Tipo de importação" (ao lado de Mensalidade/Coparticipação): "Regra empresa" — mutuamente exclusivo com "Mensalidade" (as duas configuram o mesmo tipo de lançamento `"mensalidade"`, só que de formas diferentes). Ao marcar, aparece um botão "Selecionar regra" que abre um modal listando as regras cadastradas em `portal_api/planos_saude/regras_empresa.py` (`REGRAS_EMPRESA`, registro fixo no código — nada de tela de cadastro).
**Decisão de engenharia (não pedida explicitamente, mas necessária)**: como o teto é por família e a planilha padrão do sistema é uma linha por pessoa, o valor custeado pela empresa é distribuído **proporcionalmente** entre a linha do titular e a de cada dependente (não dá pra simplesmente jogar tudo na linha do titular — quebra sempre que a mensalidade do titular sozinho já é menor que o teto). Implementado em `regras_empresa._aplica_teto_familia()`, com a última linha da família absorvendo o resto do arredondamento (mesmo cuidado já usado em `matcher._calcula_valores`). `matcher._casa_por_nome()` ganhou um segundo modo (`regra_empresa_fn`): em vez de aplicar o custeio linha a linha, acumula todas as linhas/valores da família primeiro e só aplica a regra especial no fim do laço daquela família. **A distribuição proporcional foi corrigida na rodada 60** (abaixo).
Validações de segurança adicionadas (`regras_empresa.valida_regra_empresa`, exceção própria `RegraEmpresaIncompativelError` capturada à parte em `views.py` pra mostrar a mensagem certa em vez do erro genérico de arquivo): só funciona com operadora de casamento por nome (a agregação por família depende do agrupamento que já existe em `_casa_por_nome`; Amil, por CPF, não suporta ainda) e só se a planilha padrão anexada tiver alguma linha com o `codigo_empresa` esperado pela regra (trava contra aplicar a regra da Tecnomyl numa planilha de outra empresa por engano).
Modelo ganhou `ImportacaoPlanoSaude.regra_empresa` (migração `0039`). Validado rodando o pipeline direto (fora do Django test runner, via `manage.py shell`-style script) com os dois exemplos exatos passados pelo cliente (família de R$800 → R$661,61/R$138,39) e com duas famílias reais extraídas do arquivo de teste anexado (abaixo do teto → 100% empresa) — bateu exatamente nos dois casos.
### Rodada 60 — "Regra empresa" (Tecnomyl/Unimed): correção da prioridade de distribuição do teto — dependentes primeiro
Testando com dados reais (família Caroline Fernandes + dependente Luciano Ramos Xavier, R$873,69 de mensalidade no total), o usuário reportou que a distribuição **proporcional** implementada na rodada 59 estava errada: pediu explicitamente pra priorizar abater o valor dos dependentes primeiro, e só depois disso — se sobrar teto — aplicar no titular; se ainda faltar descontar depois de esgotar o teto nos dependentes, aí sim cai desconto no titular (ou no próprio dependente, se os dependentes sozinhos já estourarem o teto).
`regras_empresa._aplica_teto_familia()` reescrita: em vez de repartir `min(total_familia, teto)` proporcionalmente entre todas as linhas, agora percorre primeiro os dependentes (na ordem em que aparecem), cada um recebendo `valor_empresa = min(seu valor, teto_restante)`, e só no fim processa o titular com o que sobrou do teto (`teto_restante`). Validado reproduzindo exatamente o caso real reportado: dependente (R$559,57) sai 100% custeado pela empresa (sobra R$102,04 de teto), titular (R$314,12) fica com `valor_empresa=R$102,04`/`valor=R$212,08` — bateu com os números que o usuário mostrou como "como deve ficar".
### Rodada 61 — "Regra empresa": "Vincular pessoa" (resolução de auditoria) ignorava a regra empresa
Usuário reportou, testando dados reais: numa importação com "Regra empresa" ativa, ao vincular manualmente um item de auditoria (nome divergente) a uma linha, o sistema lançava o valor inteiro como desconto do empregado (`valor_empresa=0`) em vez de aplicar o teto de R$661,61 da Tecnomyl.
Causa: `ImportacaoPlanoSaudeAuditoriaViewSet.resolver` sempre usava o custeio normal por pessoa (`matcher.valores_formatados_para_pessoa`, lendo `custeio_por_tipo[tipo_lancamento]`) — mas quando a importação usa regra empresa, esse campo fica vazio (`{}`) de propósito (ver rodada 59), então caía no padrão `{"modo": "empregado"}` (100% desconto), sem nenhuma noção de família/teto.
Corrigido: quando o item é de mensalidade e a importação tem `regra_empresa` configurada, `resolver()` grava o valor bruto na linha (placeholder) e chama a nova `_recalcula_familia_regra_empresa(importacao, linha)` (`views.py`) — reúne **todas** as linhas de mensalidade da mesma família (mesmo `nome_func`), recupera o valor bruto de cada uma como `valor_empresa + valor` (soma que preserva o total não importa qual split foi aplicado antes) e reaplica a regra empresa na família inteira de uma vez (`bulk_update`). Precisa reaplicar em todas as linhas, não só a recém-vinculada, porque o valor novo muda o total da família e o teto precisa ser redistribuído do zero (dependentes primeiro, titular absorve o residual — mesma prioridade da rodada 60).
`regras_empresa._aplica_teto_familia` deixou de depender do método `LinhaSistema.eh_linha_titular()` (virou `_eh_linha_titular()` duck-typed, checando `nome_dependente`/`cpf_dependente` direto) — precisa rodar tanto contra `LinhaSistema` (pipeline) quanto contra `ImportacaoPlanoSaudeLinha` (model Django, usado só no recálculo pós-vincular).
Validado com um teste de integração real (dentro de uma transação revertida de propósito, nada commitado): simulou vincular primeiro o dependente (R$172,51, abaixo do teto sozinho) e depois o titular (R$162,69) da mesma família reportada pelo usuário (Rafael Cornelius/Viviani Busko Souza) — os dois ficaram 100% custeados pela empresa em cada etapa, batendo com o esperado (família de R$335,20 no total, bem abaixo do teto de R$661,61).
### Rodada 62 — "Regra empresa" ganha campo de observações, exibido só-leitura na tela de Revisão
Pedido do usuário: quando a importação usa uma regra empresa com observação cadastrada, mostrar essa observação na tela de Revisão — sem permitir edição, só pra conferência — entre o cabeçalho ("Revisão" + informações da execução) e as abas Mensalidade/Coparticipação/Auditoria/Alterações.
`REGRAS_EMPRESA` (`regras_empresa.py`) ganhou o campo opcional `observacoes` (texto livre) — a regra `unimed_1778_tecnomyl` já nasceu com uma explicando o teto de R$661,61 e a prioridade dependentes→titular (rodada 60). Exposto via `ImportacaoPlanoSaudeDetailSerializer.regra_empresa_observacoes` (resolvido a cada carregamento a partir do registro no código, não persistido — se o texto do registro mudar depois, importações antigas refletem o texto novo também). Frontend: novo bloco `#ips-review-regra-empresa-obs` em `importacao-plano-saude.html`, populado por `abrirRevisao()` e escondido quando a importação não usa regra empresa ou a regra não tem observação cadastrada.
### Rodada 63 — "Regras de custeio salvas" também ganham observação exibida na Revisão
Usuário testou uma importação (código 1601, "1601 - Unimed Oeste PR", regra de custeio salva com observação "Custeado integralmente pelo empregado e sócio...") e notou que a observação não aparecia na tela de Revisão — só a de "Regra empresa" (rodada 62) tinha esse tratamento.
`ImportacaoPlanoSaude` ganhou `regra_custeio_salva` (FK opcional, `SET_NULL`, pra `RegraCusteioPlanoSaude`, migração `0040`) — só registro informativo de qual regra salva (se alguma) foi aplicada no formulário antes do "Processar", preenchido no submit com `regraAplicadaId` (JS) quando "Regra empresa" não está marcada. As duas nunca vêm preenchidas juntas: marcar "Regra empresa" já limpa o rastreamento da regra de custeio salva (`limparRegraSelecionada()`), e aplicar uma regra de custeio salva já desliga "Regra empresa" (`limparRegraEmpresa()` dentro de `aplicarCusteio()`) — esse segundo ponto já existia da rodada 59, só faltava o primeiro (adicionado agora).
O mesmo bloco `#ips-review-regra-empresa-obs` (rodada 62) foi reaproveitado: se não há observação de regra empresa mas há `regra_custeio_salva_observacoes` (novo campo em `ImportacaoPlanoSaudeDetailSerializer`, lendo `RegraCusteioPlanoSaude.observacoes` de verdade via FK), o bloco mostra essa observação com o rótulo "Observações da regra de custeio salva — `<nome>`".
### Rodada 64 — Nova operadora: Bradesco Saúde (1386) — primeiro PDF sem texto selecionável, extração via OCR (`docling`)
Usuário trouxe um modelo novo de fatura (Bradesco Saúde, empresa 221 - Rossoni Piotto) e perguntou se dava pra ler/converter pra parametrizar a importação, já que o PDF é "formato de imagem, sem texto selecionável". Confirmado empiricamente rodando `pdfplumber` contra o arquivo real baixado (`Downloads/221 -BRADESCO SAUDE.pdf`): `page.chars`/`page.extract_text()` vêm vazios nas 3 páginas — o documento é uma composição de dezenas de imagens raster por página (cada faixa da tabela é um bitmap próprio), sem nenhuma camada de texto. É o primeiro caso desse tipo entre as operadoras do módulo; todas as outras (Amil/Itamed/Unimed/Dental Uni/Unimed Oeste PR) têm texto selecionável e usam `pdfplumber`.
Depois de comparar duas ferramentas de conversão (MarkItDown vs. Docling — MarkItDown não tem OCR/reconstrução de tabela embutidos, feito pra documento já estruturado; Docling tem OCR local + modelo dedicado de estrutura de tabela, TableFormer), a escolha foi Docling — adicionado ao `requirements.txt` (pesado: traz `torch`/`transformers`/`opencv-python`/`pandas` como dependência transitiva, ~90 pacotes novos no freeze).
**Bug real encontrado testando contra o arquivo de verdade** (por isso vale sempre testar contra o arquivo real antes de confiar, não só contra o texto que o próprio Docling "acha" que extraiu): nas colunas estreitas "Mês/Ano"/"Valor" (sob o cabeçalho mesclado "Lançamento"), o TableFormer às vezes junta as duas numa célula só, e às vezes o valor de uma pessoa "vaza" pra célula da linha anterior (visto de fato: o valor da GABRIELA saiu dentro da célula "Valor" da MILENA, deixando a própria célula da GABRIELA vazia). Subir a resolução de renderização (`images_scale=4`) não mudou nada — não é falta de resolução, é erro de fronteira de célula do modelo mesmo. Contorno implementado em `operadoras/bradesco/saude.py` (`_extrai_valores_area`): em vez de confiar em qual célula/linha especificamente carrega o valor, concatena-se todo o texto da área "Mês/Ano+Valor" de cada linha (de cima para baixo) e extrai-se TODOS os valores monetários encontrados, preservando a ordem de leitura — depois redistribui 1 valor por linha de beneficiário, na mesma ordem (a ordem de leitura continua certa mesmo quando a fronteira de célula erra). Se a contagem final não bater 1:1 com o número de linhas, levanta erro em vez de arriscar lançar o valor errado em alguém.
Titular x dependente é decidido pela coluna "Certif." (formato `<família>/<sufixo>`, sufixo `"00"` = titular, qualquer outro = dependente daquela família — confirmado pelo usuário), não por uma coluna "Tipo" dedicada como as outras operadoras. Casamento com a planilha padrão é por nome (`chave_casamento = "nome"`, igual Itamed/Unimed/Dental Uni/Unimed Oeste PR — este arquivo também não traz CPF). Coparticipação ("Part. Seg.") ainda não foi validada com nenhum arquivo real com valor diferente de zero — o usuário disse "acreditamos que ficariam" ali; o parser já está pronto pra gerar lançamento de coparticipação quando isso acontecer (mesmo padrão "ausência de valor = zero, não gera Individuo" da Itamed), mas isso é uma suposição a confirmar, não um fato validado.
Validado rodando o parser (`BradescoSaude.extrai`) e o pipeline completo (`processa_importacao`) direto contra o arquivo real de 08/2026 + a planilha padrão de exemplo fornecida pelo usuário (empresa 221): os 4 valores de mensalidade saíram exatamente certos (incluindo o caso MILENA/GABRIELA acima) e o casamento por nome bateu certo para os 4 beneficiários presentes na planilha (mais um, LUIZ CARLOS PIOTTO, corretamente ficou sem lançamento por não aparecer na fatura daquele mês).
Registrado em `pipeline.OPERADORAS` como `"bradesco_saude": {"label": "1386 - Bradesco Saúde", ...}`.
### Rodada 80 — Buscar a planilha padrão do Questor via SQL
Até aqui, "Nova Importação" exigia exportar manualmente do Questor a "planilha padrão" (CSV) e anexá-la, além do relatório da operadora. Usuário validou uma consulta SQL contra o Questor (mesmo pacote `database/` só-leitura já usado em `empresas_questor.py`) que devolve exatamente essa informação a partir de `codigo_empresa` (já cadastrado em `RegraCusteioPlanoSaude`) + operadora + competência (mês/ano) — eliminando o passo manual na maioria dos casos.
- **Upload manual continua existindo**, como alternativa (Questor fora do ar, ou empresa ainda não migrada) — decisão explícita do usuário. "Nova Importação" ganhou um toggle "Buscar automaticamente do Questor" (padrão) / "Anexar manualmente", com um campo "Competência" (`<input type="month">`) só no primeiro modo.
- **Código da operadora**: até então só existia embutido no `label` de `pipeline.OPERADORAS` (ex. "5060 - Unimed Saúde"), extraído por *string split* onde precisava só do nome. Separado em campos próprios (`codigo_operadora`/`nome`), com `label_operadora()` calculando o label onde ainda é exibido — elimina o split e dá um valor confiável pra filtrar a consulta por operadora.
- **Nova consulta** (`sqls.questor.QuestorSQL.consulta_planilha_plano_saude`) e novo módulo `portal_api/planos_saude/questor_planilha.py` (`busca_linhas_questor`/`linhas_para_csv_bytes`) — a busca é resolvida **antes** de criar o registro `ImportacaoPlanoSaude` (uma falha de conexão nunca deixa nada órfão pra limpar, diferente do caminho de upload). `pipeline.processa_importacao` deixou de ler o arquivo sozinho (`caminho_planilha_padrao: str`) e passou a receber a lista de `LinhaSistema` já resolvida (`linhas_sistema_template`), de qualquer uma das duas origens — decisão de qual usar ficou em `views.py`.
- Mesmo vindo do Questor, um CSV é gerado (mesmo formato do upload) e salvo no campo `planilha_padrao` — preserva o histórico completo já documentado pra toda importação. Novo campo `ImportacaoPlanoSaude.competencia` (`DateField`, null) registra a competência usada, só quando a origem foi o Questor (migração `0045`).
- A "trava de conferência do código de empresa" (existente desde a rodada do Cadastro de Regras) é pulada quando a origem é Questor — redundante, já que a própria consulta já filtrou por aquele `codigo_empresa`.
**Ajuste no mesmo dia, depois do primeiro teste real**: o campo "Competência" nasceu como `<input type="month">` — usuário rejeitou o seletor nativo do browser como "horrível" e pediu o mesmo padrão já usado no Indicador de Desempenho: texto livre com máscara MM/AAAA (`mascaraCompetencia()`/`competenciaParaIso()`, cópia local das mesmas funções de `indicador-desempenho.js`), o usuário digita "082026" e o campo forma "08/2026" sozinho. Backend acompanhou: `competencia` no serializer é um `DateField` normal (mesmo padrão de `IndicadorApuracaoCreateSerializer`), recebendo o ISO `"AAAA-MM-01"` já convertido pelo JS, nunca a string mascarada crua.
**Segundo ajuste, mesmo teste real**: a coluna `DATAINICIAL` do CSV gerado via Questor saía no formato ISO (`2023-12-01`, `linha["datainicial"].isoformat()` — a renderização padrão do Postgres pra uma coluna `date`), mas o leiaute real de importação do sistema espera `DD/MM/AAAA` (`01/12/2023`) nessa coluna — mesmo formato que uma planilha padrão exportada manualmente do Questor já usa. Corrigido em `questor_planilha.busca_linhas_questor()` pra `linha["datainicial"].strftime("%d/%m/%Y")`. `data_inicial` é sempre um passthrough string em todo o pipeline (nenhum outro ponto faz parsing de data nele, só grava/lê a string), então a mudança não exigiu tocar em mais nenhum arquivo. Nenhum registro histórico ficou com o formato errado — a única importação de teste que chegou a usar a busca via Questor (antes deste ajuste) já tinha sido excluída pelo próprio usuário.
**Terceiro ajuste, mesmo dia — regra de edição da tabela de revisão revista**: até aqui, todo campo de `ImportacaoPlanoSaudeLinha` era editável em qualquer linha na tela de Revisão (decisão de uma rodada anterior). Vendo dados reais de uma importação via Questor (22 linhas, várias famílias), o usuário pediu pra restringir: só `Valor Empresa`/`Valor` editáveis numa linha que já veio do processamento (upload ou Questor) — os demais campos (cadastro da pessoa: nome, CPF, código, data...) só ficam editáveis numa linha incluída manualmente via "Adicionar linha", e só nela. Implementado 100% no frontend (`importacao-plano-saude.js`, `linhasIncluidasManualmente()`), sem campo novo: reaproveita o sinal que já existia em `ImportacaoPlanoSaudeAlteracao` (toda linha criada via "Adicionar linha" já gerava um registro `tipo="inclusao"` ali, só pra alimentar a aba "Alterações" — agora também decide, no cliente, quais linhas ganham os campos extras liberados). O backend continua aceitando PATCH em qualquer campo (nenhuma trava nova no serializer/model) — é uma restrição de UI, não de permissão.
**Quarto ajuste, mesmo dia — trava de edição depois de "Gerar Arquivo"**: o ajuste anterior já restringia *quais campos*, mas o usuário notou que uma importação já `concluida` (arquivo já gerado/entregue) continuava 100% editável — pediu pra travar de vez, com um botão "Editar" explícito pra reabrir quando for realmente necessário corrigir algo depois da conclusão. Diferente do ajuste anterior, este saiu **também no backend**: `_garante_importacao_em_revisao(importacao)` (nova função em `views.py`) é chamada em todo ponto que escreve dentro de uma importação já criada — `ImportacaoPlanoSaudeLinhaViewSet.perform_create/update/destroy`, `ImportacaoPlanoSaudeAuditoriaViewSet.resolver`, `ImportacaoPlanoSaudeAlteracaoViewSet.reverter` — e recusa (400) se `status == "concluida"`. Novo endpoint `POST /api/importacoes-plano-saude/{id}/reabrir/` (`ImportacaoPlanoSaudeViewSet.reabrir`) volta `status="revisao"` e zera `concluida_em` — único jeito de destravar. `gerar()` em si nunca é bloqueado (sempre pode regerar/rebaixar o arquivo já concluído). No frontend, a tela de Revisão some com todo controle de edição (inputs viram texto, "×"/"Adicionar linha"/"Vincular pessoa"/"Reverter" somem) quando `status === "concluida"`, e mostra um botão "Editar" (ao lado de "Gerar Arquivo") que chama o `reabrir()` e volta tudo ao normal. Testado ponta a ponta via `APIRequestFactory`/`force_authenticate` (sem afetar dados reais, dentro de uma transação revertida): reabrir muda o status, edita normalmente em revisão, e volta a bloquear ao marcar concluída de novo.
**Quinto ajuste, mesmo dia**: "Gerar Arquivo" passou a voltar direto pro histórico depois do download disparar (`showView("list")` + `refreshList()`) — como a importação já fica `concluida`/travada nesse momento (ajuste anterior), não fazia sentido continuar na tela de Revisão sem nada pra fazer ali.
**Sexto ajuste, mesmo dia — filtro no histórico**: com o histórico crescendo (23 importações já), o usuário pediu pra filtrar a lista, não só ordenar — ex.: ver todas as execuções da empresa 221, ou combinar empresa 221 + operadora 3755. Primeira versão foi um campo de texto por coluna (Cód. Empresa/Operadora/Criado por) + um `<select>` pra Status. 100% client-side, sem endpoint novo.
**Sétimo ajuste, mesmo dia — filtro revisado pra "estilo Excel"**: usuário pediu pra trocar os campos de texto por um filtro de planilha de verdade — clicar num ícone de funil na coluna e marcar/desmarcar os valores que aparecem numa lista, como no AutoFilter do Excel. Reescrito (`criarFiltroColuna()`) como um popup por coluna com busca + checklist dos valores distintos daquela coluna (reaproveita `.checklist-box` de Perfis de Acesso, sem CSS/componente novo pra isso) e botões "Aplicar"/"Limpar" — os filtros das 4 colunas continuam combinando entre si (AND). Precisou de um ajuste de CSS colateral: `.pa-table-wrap` corta com `overflow:hidden` pra arredondar os cantos da tabela, o que cortaria o popup também — resolvido com uma classe extra só nesta tabela (`.ips-list-table-wrap`) sobrescrevendo pra `overflow:visible`.
### Rodada 83 — Múltiplos arquivos de operadora + Unimed Saúde em PDF
Cliente real (Fallkner Ribeiro Borges) em que a Unimed manda **dois PDFs separados** (mensalidade + coparticipação analítico) em vez do CSV único já suportado — diferente de toda operadora até então, que sempre mandava um único arquivo. Duas mudanças, decididas em planejamento explícito antes de implementar dado o tamanho:
- **"Arquivo da operadora" passou a aceitar 1+ arquivos** — mudança de arquitetura que afeta todas as operadoras, não só a Unimed. `ImportacaoPlanoSaude.arquivo_operadora` (FileField único) foi substituído por `ImportacaoPlanoSaudeArquivoOperadora` (FK + `arquivo` + `ordem`), migração em 3 passos (`0048` cria o model novo + torna o campo legado `blank=True`; `0049` faz o backfill via `RunPython`, reapontando pro mesmo caminho já salvo sem copiar bytes; `0050` remove o campo legado) — mesmo padrão já usado em `IndicadorDepartamento`/`RegraCusteioPlanoSaude.codigo_empresa`. `pipeline.processa_importacao` passou a receber uma lista de caminhos, chamando `OperadoraParser.extrai()` uma vez por arquivo. Frontend: `<input type="file" multiple>` + lista dinâmica com validação e remoção individuais.
- **Bug real encontrado testando de ponta a ponta** (2 arquivos idênticos da mesma operadora/tipo, via `APIRequestFactory` real): simplesmente concatenar os indivíduos de cada arquivo não bastava — se dois arquivos contribuem pra mesma pessoa e mesmo `tipo_lancamento`, `_aplica_regra_custeio` (matcher.py) **grava** o valor por linha, não acumula, então o segundo arquivo sobrescrevia o valor do primeiro. Corrigido com `_agrega_individuos_entre_arquivos()` (`pipeline.py`), somando por (`numero_beneficiario`, `tipo_lancamento`) antes do casamento — mesmo padrão que cada parser já fazia **dentro** de um arquivo, agora replicado entre arquivos.
- **Parser da Unimed Saúde (`operadoras/unimed/saude.py`) ganhou o segundo formato**: `extrai()` detecta automaticamente, pelo conteúdo da primeira página, se o PDF é o relatório de mensalidade ("BENEFICIARIOS COM FATURAMENTO NO MES") ou o analítico de coparticipação ("SERVIÇOS PRESTADOS"/"ANALITICO") — nunca pede pro usuário escolher. Os dois usam `pdfplumber` com `layout=True` (mesma técnica já validada em `ItamedSaude`). Confirmado com o usuário: a coparticipação por beneficiário é a soma do "Vl Total" de cada serviço (a coluna "Tt Copar", valor fixo repetido em todo o documento, não é usada).
- **3 bugs reais encontrados e corrigidos testando com PDFs gerados via `reportlab`** (reproduzindo linha a linha o texto dos exemplos, através do fluxo completo de `create()`, não mocks): (1) os valores nos PDFs vêm em formato americano (ponto decimal, vírgula de milhar), diferente do formato BR do resto do pipeline — `_valor_pdf_para_float` separada; (2) o grau "FILHO(A)" nunca casava porque a regex usava `\b` logo depois de `)`, que não é caractere de palavra (`\b` nunca bate entre dois não-palavra) — trocado por `(?=\s|$)`; (3) `numero_titular` de um dependente estava sendo setado como o código da família, mas o resto do pipeline (`nomes_titular_por_numero`/`_casa_por_nome` em matcher.py) espera o `numero_beneficiario` do próprio titular ali — sem isso, a família nunca era resolvida e tudo caía em auditoria "sem titular identificado".
- **Ressalva que permanecia**: o arquivo real da Unimed nunca tinha sido processado (só o texto/imagem colados na conversa) — confirmada como necessária no mesmo dia: o usuário testou pela tela e a coparticipação deu "Nenhum beneficiário foi encontrado neste arquivo."
**Ajuste no mesmo dia, depois do teste real — usuário forneceu o caminho dos dois arquivos no disco**: em vez de tentar adivinhar a estrutura de novo a partir de texto colado (já teria sido a terceira vez), pedido e recebido o caminho local dos PDFs, rodando o `pdfplumber` de verdade contra eles. Duas descobertas reais, só possíveis com o arquivo de verdade — **o texto de um PDF colado numa conversa não é o que `pdfplumber.extract_text()` de fato produz**, então as duas primeiras tentativas (acima) estavam desenhadas sobre uma estrutura que nunca existiu:
- **Mensalidade**: `extract_text()` simples (sem `layout=True`) já devolve linhas bem formadas — a regex funcionou de primeira contra o arquivo real, bateu exatamente com "Total por Contratante: 6.061,74".
- **Coparticipação analítica**: `benef`+`nome`+`grau` vêm **colados sem espaço nenhum** (ex.: "0975.0167003824292ANDREIA STORMTITULAR") — corrigido ajustando a regex pra não exigir espaço entre eles. Mas surgiu um problema mais sério: o **nome do beneficiário sai truncado em ~13 caracteres** por largura de coluna ("ANDREIA STORMOSKI LARA" → "ANDREIA STORM"), o que faria casamento por nome falhar sistematicamente pra qualquer nome mais longo que a coluna — não é um bug de regex, é informação perdida de verdade no relatório. Como esse mesmo relatório traz CPF completo e confiável, a solução foi trocar a estratégia de casamento: `OperadoraParser` ganhou `chave_casamento_para_tipo(tipo_lancamento)` (default: mesmo valor de sempre, backward-compatible pra todo outro parser) e `UnimedSaude` a sobrescreve pra devolver `"cpf"` só quando `tipo_lancamento="coparticipacao"` **e** a origem foi o PDF (rastreado numa flag de instância setada em `extrai()` — mensalidade, sem CPF em nenhum formato, continua em `"nome"`). Também corrigido: linhas "Pct:MED"/"Pct:HOS"/"Pct:MAT" (detalhamento de um item já somado no valor principal) estavam sendo contadas como itens de serviço de verdade (colidindo com a mesma regex de tipo de serviço), duplicando o valor — agora ignoradas explicitamente.
- **Validado de ponta a ponta com os dois arquivos reais** através do fluxo completo de `create()`: bateu exatamente com "Total da Familia"/"Total da Sequencia" impressos no próprio relatório (1.372,08 de coparticipação, 6.061,74 de mensalidade) e, usando a planilha padrão real da empresa 1123, cada família presente na planilha casou centavo a centavo — a única família ausente da planilha de teste foi corretamente pra auditoria "não cadastrado", não ignorada.
### Rodada 84 — Vínculos de nome salvos (DE/PARA) — reaplicação automática de "Vincular pessoa"
Usuário perguntou se a resolução manual de nome divergente ("Vincular pessoa") precisava ser refeita em toda execução futura, ou se podia ficar guardada "como se fosse um DE/PARA". Confirmado: sim — guardar, reaplicar automaticamente em importações futuras da mesma operadora+empresa, e mostrar cada aplicação automática na aba Alterações com um botão pra apagar o vínculo. Continua não sendo aproximação — só existe depois de uma confirmação humana explícita (ver "Vínculos de nome salvos (DE/PARA)" no `CLAUDE.md` desta pasta pro detalhamento completo).
- Novo model `VinculoNomeOperadora` (migração `0051`, junto com `ImportacaoPlanoSaudeAlteracao.TIPO_VINCULO_AUTOMATICO` + FK `vinculo_nome`) — `codigo_empresa` fica cru no model (normalizado só em `views.py`, pra evitar um import circular entre `models.py` e `empresas_questor.py`, que já importa de `models.py`).
- `matcher._casa_por_nome` ganhou `vinculos_por_nome`/`tipo_lancamento`: quando titular ou dependente não bate por nome exato, consulta o DE/PARA antes de cair em auditoria; cada aplicação automática vira um `VinculoAplicado` (dataclass pura, sem ORM), devolvido por `pipeline.processa_importacao` em `ResultadoProcessamento.vinculos_aplicados`.
- `ImportacaoPlanoSaudeAuditoriaViewSet.resolver()` passou a gravar (`update_or_create`) o `VinculoNomeOperadora` depois de aplicar a resolução manual. `ImportacaoPlanoSaudeViewSet.create()` busca os vínculos relevantes (`_carrega_vinculos_por_nome`) antes de processar, e depois do `bulk_create` das linhas correlaciona cada `VinculoAplicado` (por índice dentro do tipo de lançamento) com a linha já persistida, criando um `ImportacaoPlanoSaudeAlteracao` por vínculo aplicado.
- Botão "Apagar vínculo" (aba Alterações, mesmo endpoint `reverter()`) zera o valor lançado na linha (redistribuindo a regra empresa da família, se houver) e apaga o `VinculoNomeOperadora` — a divergência volta a cair em auditoria nas próximas importações.
- Testado via `APIRequestFactory` dentro de uma transação revertida (nada persistido nos dados reais): matcher.py aplicando/não aplicando o DE/PARA corretamente, `resolver()` criando o vínculo, `_carrega_vinculos_por_nome` encontrando-o, e `reverter()` zerando a linha + apagando o vínculo.
### Rodada 85 — Nova operadora: Unimed Vitória (4750)
Usuário forneceu os dois PDFs reais do cliente Weitnauer Brasil (empresa 792 na planilha padrão): "Demonstrativo Analítico de Pré Pagamento" (mensalidade) e "Extrato de Co-Participação" (coparticipação), sempre em arquivos separados — nenhum dos dois traz CPF, casamento por nome.
- `operadoras/unimed_vitoria/saude.py` (`UnimedVitoriaSaude`), registrada em `pipeline.OPERADORAS["unimed_vitoria_saude"]`. Detecção automática do tipo pelo conteúdo (marcador "DEMONSTRATIVO" vs. "CO-PARTICIPA" na página 1), mesmo espírito da Unimed do Paraná.
- Particularidade de extração: no PDF de mensalidade, a coluna de nome quebra em 2 linhas físicas quando o nome é longo, num `top` diferente (mas próximo) da linha de dados — nem `extract_text()` nem `extract_text(layout=True)` resolvem isso sem ambiguidade. Solução: reconstruir as linhas a partir de `extract_words(extra_attrs=["fontname","size"])` agrupadas por posição vertical (tolerância calibrada contra o arquivo real) e usar sempre o cabeçalho em negrito (nome completo, sem quebra, distinguido por ser 100% maiúsculo e não começar com dígito) como fonte do nome — nunca a linha de dados quebrada. No PDF de coparticipação, as colunas não têm espaço literal nenhum entre si (só posição) — `extract_words()` tokeniza certo, concatenar `page.chars` direto colaria "1CONSULTA" sem espaço.
- Validado rodando o parser e o `pipeline.processa_importacao` completo contra os dois arquivos reais + a planilha padrão real (empresa 792): mensalidade bateu R$ 340,74 e coparticipação R$ 55,57 (os mesmos valores impressos no próprio relatório), casamento por nome correto contra a planilha, zero itens de auditoria.
- **Limitação conhecida, não validada**: os dois arquivos de exemplo só têm titular, sem nenhum dependente, e nenhum dos dois relatórios traz um marcador textual "Titular"/"Dependente" explícito. A classificação usada (sequência "00" da carteirinha = titular, qualquer outra = dependente; família = tudo antes da sequência) é a convenção nacional já conhecida de outras Unimeds, mas nunca confirmada contra um arquivo real desta operadora com dependente — testar com um caso real antes de confiar nela de olhos fechados (ver `CLAUDE.md` desta pasta, seção "Unimed Vitória (4750)").
### Rodada 86 — Nova operadora: SulAmérica Odonto (4726)
Usuário forneceu o PDF real do cliente Weitnauer Brasil (empresa 792 na planilha padrão, competência 08/2026) — relatório "Conferência de Faturamento PJ (Completo)" do sistema "IS Odonto", plano "ODONTO MAIS PME", só mensalidade (sem coparticipação). Diferente das duas Unimeds já portadas: este relatório traz **CPF de todo mundo** e um campo textual explícito de "Grau parentesco" — casamento por CPF, sem nenhuma suposição sobre numeração de carteirinha.
- `operadoras/sulamerica/odonto_mensalidade.py` (`SulAmericaOdontoMensalidade`), registrada em `pipeline.OPERADORAS["sulamerica_odonto_mensalidade"]`.
- Usuário avisou que a coluna "Valor" tem um totalizador por família impresso no relatório, mas o lançamento deve ser feito por beneficiário — o parser sempre lê o valor da linha individual (última coluna monetária de cada linha de beneficiário), nunca a linha de subtotal da família (que nem tem código nenhum pra casar com nada, então já ficaria de fora naturalmente).
- Mesma técnica de reconstrução de linha por posição (`extract_words()` agrupadas por `top`) já usada na Unimed Vitória, porque o nome de um beneficiário longo quebra pro relatório — só que aqui o corte é mais agressivo (o próprio relatório trunca a última letra da palavra, ex. "SILV" em vez de "SILVA"), sem prejuízo nenhum já que o casamento é por CPF, não por nome.
- Validado rodando o parser e o `pipeline.processa_importacao` completo contra o arquivo real + a planilha padrão real: 15 beneficiários extraídos, R$ 437,40 no total (bate com "Total R$ 437,40" impresso no relatório); 13 casaram certo por CPF contra a planilha padrão de teste, os outros 2 (ausentes dessa planilha) foram corretamente para auditoria "CPF não encontrado" em vez de ignorados/silenciosos.
> Operadoras adicionadas depois desta rodada (Bradesco Dental/3759, Amil Odonto/898, SulAmérica Saúde/5775 via Ottimizza) não têm uma rodada numerada correspondente registrada em `plano.md` — o estado atual de cada uma está documentado em `CLAUDE.md` desta pasta.

View File

@ -1,12 +0,0 @@
# Importação de Plano de Saúde (Utilitários)
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico completo (pacote `planos_saude/`, cada operadora parametrizada). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Ver também a skill `importacao-questor-plano-saude` (`.claude/skills/`) para contexto de negócio (quais operadoras/empresas já estão validadas, o que falta).
Única aplicação dentro de "Utilitários" — importa o relatório de faturamento de uma operadora de plano de saúde/odontológico e gera o arquivo de lançamento no leiaute do Questor, com auditoria do que não pôde ser casado automaticamente contra a planilha padrão. Hoje suporta 9 parsers de operadora (Amil, Itamed, Unimed em 3 variantes, Dental Uni, Bradesco em 2 variantes, SulAmérica em 2 variantes) e um banco de regras de custeio por empresa+operadora, com histórico completo de cada importação e reversão de qualquer alteração feita na revisão.
## Onde mexer
- `portal_api/planos_saude/` — `modelos.py` (dataclasses), `matcher.py` (casamento por CPF ou nome), `leiaute_sistema.py` (planilha padrão do Questor), `pipeline.py` (`OPERADORAS`, `processa_importacao`), `regras_empresa.py` (regras especiais fixas no código), `questor_planilha.py` (busca a planilha padrão direto do Questor via SQL), `operadoras/<nome>/` (um parser por operadora).
- `ImportacaoPlanoSaude`, `ImportacaoPlanoSaudeLinha`, `ImportacaoPlanoSaudeAuditoria`, `ImportacaoPlanoSaudeAlteracao`, `RegraCusteioPlanoSaude`, `VinculoNomeOperadora` (`portal_api/models.py`).
- `importacao-plano-saude.html`/`static/js/importacao-plano-saude.js`/`static/css/importacao-plano-saude.css`.
- Pra adicionar uma operadora nova: ver o `CLAUDE.md` desta pasta antes de escrever o parser — documenta decisões de negócio já validadas com o cliente que não devem ser reinterpretadas sem confirmar de novo.