Inclusão do Mais informações e correção visual da aplicação.

This commit is contained in:
Gabriel 2026-08-24 10:54:17 -03:00
parent 1fe649ac86
commit 8d2650812d
35 changed files with 746 additions and 115 deletions

View File

@ -97,7 +97,7 @@ Um único app, `portal_api/`:
| Arquivo | Conteúdo | | Arquivo | Conteúdo |
|---|---| |---|---|
| `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver seção "Liderança" abaixo) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados), `Departamento` (só `nome`, `unique=True` — cadastro inline pela própria tela de Usuários, sem tela de administração dedicada como `PerfilAcesso`), `PerfilAcesso` (`permissoes` em `JSONField`, mesmo formato aninhado do frontend; mais o booleano dedicado `gerencia_permissoes`), `CompromissoAgenda`, `Favorito`, `WidgetUsuario`, `NotificacaoDispensada`, `LinkFerramenta` (`icone` é `ImageField`, requer Pillow), `LinkFerramentaFavorito` (favorito por usuário de um cartão de Links & Ferramentas — não confundir com `Favorito`), `AcessoGeralSecao`/`AcessoGeral` (cadastro de logins/acessos compartilhados da aplicação "Acessos Gerais", ver seção própria abaixo), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver seção "Ramais" abaixo), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo). | | `models.py` | `Usuario` (`AbstractUser` + `nome`, M2M `perfis`, M2M `departamentos` (pra `Departamento`, ver abaixo), campos cadastrais opcionais `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` (`CharField`, `blank=True`) e `data_aniversario` (`DateField`, `null=True, blank=True`), `lideranca` (`BooleanField`, é gerente/coordenador) e M2M `liderados` (self-referential, `symmetrical=False`, `related_name="lideres"` — ver seção "Liderança" abaixo) — `email` já vem de `AbstractUser`, não precisou de campo novo; método `permissao_app(module_key, app_key)` — união genérica de um flag de `apps` entre os perfis vinculados; 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 seção própria abaixo), `Ramal` (linha **avulsa** da tela de Ramais, sem `Usuario` por trás — colaboradores de verdade aparecem automaticamente na listagem, sem precisar de uma linha aqui; ver seção "Ramais" abaixo), `RamalAusencia` (período de ausência de um colaborador, com `esta_ativa()` calculando "ausente agora" em vez de armazenar), `TelefoneExterno` (subtela "Telefones Externos" de Ramais, sem `Usuario` por trás), `FuncaoTelefonia` (subtela "Funções de Telefonia" de Ramais, `Meta.ordering` por `comando` reproduz a ordem esperada sem campo de ordem manual), `ImportacaoPlanoSaude`/`ImportacaoPlanoSaudeLinha`/`ImportacaoPlanoSaudeAuditoria` (ferramenta "Importação de Plano de Saúde" em Utilitários, ver seção própria abaixo). |
| `catalogo.py` | Fonte única da verdade do catálogo de módulos/aplicações do menu (`MODULES`, `MODULE_APPS`) — exposto só leitura via `GET /api/catalogo/`. Ao adicionar uma seção/aplicação nova ao menu, editar **aqui**, não em `static/js/profiles.js` (que só cacheia o payload recebido). | | `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"). | | `serializers.py` | `PerfilAcessoSerializer`, `DepartamentoSerializer`, `UsuarioResumoSerializer` (`id`/`nome`/`departamentos`, usado nos dois lados de `liderados` e por `/api/usuarios-resumo/`), `UsuarioSerializer` (escrita, aceita `senha`+`perfis`+`departamentos`+`liderados`)/`UsuarioListSerializer` (leitura, `perfis`/`departamentos`/`liderados` aninhados), `CompromissoAgendaSerializer` (`sou_dono`, `dono_nome`, `dono_username`), `FavoritoSerializer`, `WidgetUsuarioSerializer`, `NotificacaoDispensadaSerializer`, `LinkFerramentaSerializer`, `LinkFerramentaFavoritoSerializer`, `AcessoGeralSecaoSerializer`, `AcessoGeralSerializer`, `RamalSerializer` (só das linhas avulsas — ver seção "Ramais"), `RamalAusenciaSerializer`, `TelefoneExternoSerializer`, `FuncaoTelefoniaSerializer`, `ImportacaoPlanoSaudeCreateSerializer`/`ImportacaoPlanoSaudeListSerializer`/`ImportacaoPlanoSaudeDetailSerializer`/`ImportacaoPlanoSaudeLinhaSerializer`/`ImportacaoPlanoSaudeAuditoriaSerializer` (ver seção "Importação de Plano de Saúde"). |
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). | | `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). |
@ -116,13 +116,14 @@ Um único app, `portal_api/`:
| `/api/auth/csrf/` | GET | garante o cookie `csrftoken` | | `/api/auth/csrf/` | GET | garante o cookie `csrftoken` |
| `/api/auth/login/` | POST | `{username, password}` → cria sessão | | `/api/auth/login/` | POST | `{username, password}` → cria sessão |
| `/api/auth/logout/` | POST | encerra sessão | | `/api/auth/logout/` | POST | encerra sessão |
| `/api/me/` | GET | usuário logado + `perfis` + `departamentos` (os próprios, pra alimentar o seletor de "Meu departamento" do Calendário Individual) + `gerencia_permissoes` + `permissoes_efetivas` (união já calculada no servidor) | | `/api/me/` | 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/senha/` | POST | `{senha_atual, nova_senha}` |
| `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver seção "Liderança") | | `/api/me/liderados/` | PATCH | `{liderados: [id, ...]}` — só se `me.lideranca`; auto-gerenciamento de liderados (ver seção "Liderança") |
| `/api/catalogo/` | GET | módulos/aplicações/subgrupos do menu | | `/api/catalogo/` | GET | módulos/aplicações/subgrupos do menu |
| `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver "Feriados no Calendário Individual" abaixo | | `/api/feriados/?ano=AAAA` | GET | feriados nacionais + estaduais (PR) do ano pedido (default: ano atual), via lib `holidays` — ver "Feriados no Calendário Individual" abaixo |
| `/api/usuarios-resumo/` | GET | lista enxuta (`id`/`nome`) de usuários ativos — alimenta o seletor de liderados, sem exigir `gerencia_permissoes` (mesmo padrão de `/api/ramais/usuarios/`) | | `/api/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/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/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/departamentos/`, `/api/departamentos/{id}/` | GET/POST/PUT/DELETE | CRUD de departamento — só quem tem `gerencia_permissoes`; usado pela tela de Usuários pra listar o checklist e cadastrar um novo departamento inline (sem tela própria) |
| `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver "Inativar usuário" abaixo) | | `/api/usuarios/`, `/api/usuarios/{id}/` | GET/POST/PATCH/DELETE | CRUD de conta — só quem tem `gerencia_permissoes`; `departamentos` é M2M igual `perfis` (lista de ids na escrita, objetos aninhados na leitura); `is_active` é gravável via PATCH (inativar/reativar, ver "Inativar usuário" abaixo) |
@ -198,7 +199,7 @@ O item "Calendário De Paula" no menu **não é uma página local** — é um `<
## Ordem de `<script>` e por que ela não quebra nada ## Ordem de `<script>` e por que ela não quebra nada
Todas as páginas-shell (todas exceto `index.html`) carregam o mesmo prefixo de scripts, na mesma ordem: `api.js → dual-select.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ...`, seguido de scripts específicos da página. Nenhum script usa `defer` exceto `theme.js` (que fica no `<head>`). Todas as páginas-shell (todas exceto `index.html`) carregam o mesmo prefixo de scripts, na mesma ordem: `api.js → confirm-modal.js → dual-select.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ...`, seguido de scripts específicos da página. Nenhum script usa `defer` exceto `theme.js` (que fica no `<head>`). `confirm-modal.js` (`pidConfirm()`, ver "Modal de confirmação genérico" abaixo) é a única exceção que também é carregada em `index.html` (logo depois de `api.js` ali também) — é genérico o bastante pra fazer sentido em qualquer página, inclusive o login.
Isso importa porque, por exemplo, `access.js` chama `pidRequireAuth()` de `auth.js`, que só aparece **antes** dele na tag `<script>` — mas mesmo quando a ordem fosse invertida funcionaria, porque toda chamada cross-arquivo acontece dentro de um callback de `document.addEventListener("DOMContentLoaded", ...)`, nunca no nível superior do script. Como todo `<script>` sem `defer` executa (e portanto declara suas funções) antes do evento `DOMContentLoaded` disparar, a ordem relativa entre arquivos não importa — só importa que todos estejam presentes na página antes desse evento. Ao adicionar um novo arquivo JS compartilhado, não é preciso se preocupar em "colocá-lo antes de quem o usa", desde que toda chamada fique dentro de um handler de `DOMContentLoaded` (ou de uma função só invocada por um). Isso importa porque, por exemplo, `access.js` chama `pidRequireAuth()` de `auth.js`, que só aparece **antes** dele na tag `<script>` — mas mesmo quando a ordem fosse invertida funcionaria, porque toda chamada cross-arquivo acontece dentro de um callback de `document.addEventListener("DOMContentLoaded", ...)`, nunca no nível superior do script. Como todo `<script>` sem `defer` executa (e portanto declara suas funções) antes do evento `DOMContentLoaded` disparar, a ordem relativa entre arquivos não importa — só importa que todos estejam presentes na página antes desse evento. Ao adicionar um novo arquivo JS compartilhado, não é preciso se preocupar em "colocá-lo antes de quem o usa", desde que toda chamada fique dentro de um handler de `DOMContentLoaded` (ou de uma função só invocada por um).
@ -284,6 +285,31 @@ Substituiu o antigo `<select>` + botão "Vincular" + lista simples com X pra rem
- Abrir a aba de um perfil diferente (`renderUsers()`, chamada por `openEdit()`) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior. - Abrir a aba de um perfil diferente (`renderUsers()`, chamada por `openEdit()`) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior.
- Sem endpoint novo — 100% reaproveitamento de `GET /api/usuarios/` (`UsuarioListSerializer`, já expõe `email`) e `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`, já existia). - Sem endpoint novo — 100% reaproveitamento de `GET /api/usuarios/` (`UsuarioListSerializer`, já expõe `email`) e `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`, já existia).
## Ajuda de aplicação ("Mais informações")
Botão "?" (`.info-tooltip`, `components.css`) ao lado do nome de uma aplicação — ao passar o mouse mostra a dica "Mais informações" (tooltip CSS puro, sem JS de posicionamento, já que a posição relativa ao próprio botão nunca varia); ao clicar, abre um modal com um texto livre descrevendo objetivo/processo/cuidados/resultado esperado daquela ferramenta. Visualizar é liberado a qualquer usuário autenticado; **editar é restrito a quem tem o perfil "Inovação" vinculado** — uma checagem de **nome fixo** (`Usuario.eh_perfil_inovacao()`/`models.PERFIL_INOVACAO_NOME`, mesmo padrão já usado pro selo "Restrito" de Relatórios Gerenciais, nome `=== "Diretoria"`), **não** uma flag na árvore de permissões — decisão explícita do usuário, pra não precisar aparecer em Perfis de Acesso.
- **Model** (`AjudaAplicacao`): chave natural `app_key` (mesma ideia de `app_id`/`notif_id`/`tipo` de `Favorito`/`NotificacaoDispensada`/`WidgetUsuario` — sem FK pra `Usuario`, é um texto compartilhado, igual pra quem abrir o modal) + `texto` (`TextField`, `blank=True`, `validators=[validar_tamanho_texto_ajuda_aplicacao]` — mesmo teto de 2.000.000 caracteres de `AcessoGeral.observacoes`, generoso o bastante pra várias imagens embutidas) + `atualizado_em`/`atualizado_por`. `AjudaAplicacao.para_app(app_key)` faz `get_or_create` — mesmo padrão de "singleton por chave, criado sob demanda" já usado em `ParametroFiscalCustoContratacao.atual()`/`EmpresaQuestor`.
- **Endpoint** `GET`/`PATCH /api/ajuda-aplicacoes/<app_key>/` (`ajuda_aplicacao_view`, função simples — não é `ModelViewSet`, mesmo estilo de `parametros_fiscais_custo_contratacao_view`): GET só exige `IsAuthenticated`; PATCH também exige `request.user.eh_perfil_inovacao()`, senão `PermissionDenied`. `GET /api/me/` ganhou o campo `eh_perfil_inovacao` (calculado no servidor, igual a `gerencia_permissoes`) pra o frontend saber se mostra os botões "Editar" do modal.
- **Texto aceita imagens embutidas, igual às Observações de Acessos Gerais** (pedido explícito do usuário) — mesmo mecanismo, reaproveitado: `<div contenteditable>` no cliente com colar (`Ctrl+V`)/arrastar imagem (`insertImageFile()`, até 2MB), convertida em data URI e inserida via `document.execCommand("insertImage", ...)`; sanitizado no servidor com `nh3.clean()` antes de salvar (`AjudaAplicacaoSerializer.validate_texto`). As constantes de allowlist do nh3 (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES`, `serializers.py`) foram **generalizadas** (antes prefixadas `ACESSO_GERAL_*`) pra serem compartilhadas pelos dois campos — mesmo allowlist estrito (texto básico + `<img>`, sem `<a>`/`<script>`/atributos de evento, `data:` liberado pra imagem embutida). A UI em si (`.ajuda-modal__editor`/`.ajuda-modal__editor-hint` em `components.css`) é uma **reimplementação** de `.ag-richtext` (não um reaproveitamento direto), porque este modal pode aparecer em qualquer página, e `.ag-richtext`/`acessos-gerais.js` são escopados só a `acessos-gerais.html`.
- **Frontend** (`static/js/ajuda-aplicacao.js`, reusável — igual ao espírito de `dual-select.js`): `pidCriarBotaoAjuda(botaoId, appKey, tituloApp)` liga o clique de um botão já existente no HTML da página; o modal em si (`#ajuda-aplicacao-modal`) é **criado sob demanda e injetado em `document.body` pelo próprio JS** (não precisa ser hand-authored em cada página) e reaproveitado por todos os botões dela — só um pode estar aberto por vez. Texto em modo leitura via `innerHTML` (seguro porque já vem sanitizado do backend, mesmo raciocínio de `.ag-view-observacoes`); quem tem `eh_perfil_inovacao` vê um botão "Editar" que troca pro editor rico in-place (mesmo padrão "Cancelar"/"Salvar" já usado em "Editar Ausência" de Ramais).
- **Confirmação ao sair sem salvar** (pedido explícito do usuário): enquanto o modal está em modo edição (`modal.dataset.editing = "true"`, setado por `entrarEdicao()`/limpo por `renderVisualizacao()`), fechar o modal por qualquer caminho — botão "Fechar", clicar fora (overlay) ou "Cancelar" — dispara `await pidConfirm("Sair sem salvar as alterações?", { perigoso: true })` (ver "Modal de confirmação genérico" abaixo); só fecha/descarta se confirmado. "Salvar" nunca pede confirmação (não há o que descartar). `pidFecharAjudaAplicacaoModal()` é a única função que fecha o modal de fato (agora `async`), então o botão "Fechar" e o clique no overlay (que já chamavam essa função) ganharam a checagem de graça; só "Cancelar" precisou de uma checagem própria antes de chamar `renderVisualizacao()`.
### Modal de confirmação genérico (nunca `window.confirm`/`window.alert`)
`static/js/confirm-modal.js` (incluído logo depois de `api.js` em **todo** shell, inclusive `index.html`) — `pidConfirm(mensagem, opcoes)` devolve uma `Promise<boolean>` (`true` = confirmado, `false` = cancelado ou fechado clicando fora), num modal `.modal-overlay`/`.modal-card` no padrão visual do Portal, nunca o diálogo nativo do navegador. **Decisão explícita do usuário**: o `window.confirm()` nativo do Chrome mostra o IP/porta do servidor na barra de título do popup e quebra a identidade visual do app — todo popup novo deve usar este modal em vez disso.
- `opcoes` (todas opcionais): `titulo` (default "Confirmar ação"), `textoConfirmar`/`textoCancelar` (defaults "Confirmar"/"Cancelar"), `perigoso` (troca o botão de confirmar pra `.btn-danger-outline`, mesmo estilo já usado em ações destrutivas como "Excluir selecionadas", em vez do `.btn-solid` padrão).
- Único modal (`#pid-confirm-modal`), criado sob demanda e reaproveitado — **empilha por cima** de qualquer modal já aberto via `.modal-overlay--top` (`components.css`, só `z-index` maior que o `.modal-overlay` padrão), então não precisa fechar o modal atual antes de perguntar; o modal por trás continua visível (dimmed), só o de confirmação recebe o clique.
- Reaproveita `.modal-card__title`/`.modal-card__subtitle` (`components.css`, já genéricos) pro título/mensagem — não precisou de classes novas de texto, só `.confirm-modal__cancelar-btn`/`.confirm-modal__confirmar-btn` como seletores de DOM pro próprio `confirm-modal.js`.
- **`pidAlert(mensagem, opcoes)`** é a contrapartida pra `window.alert()` — mesmo modal, um único botão (default "OK", sem "Cancelar"), devolve `Promise<void>`. Compartilha a mesma instância de `#pid-confirm-modal`/`pidConfirmOuAlerta()` internamente — `pidConfirm`/`pidAlert` só chamam essa função com `modoAlerta` diferente.
- **Todo `window.confirm()`/`window.alert()` do app foi migrado pra `pidConfirm()`/`pidAlert()`** (rodada de varredura completa, pedido explícito do usuário — "migre as demais"): `acessos-gerais.js` (excluir acesso, excluir seção), `calendar-individual.js` (excluir compromisso), `links-ferramentas.js` (remover link, erro ao favoritar), `importacao-plano-saude.js` (excluir regra de custeio, remover linha, reverter alteração, excluir importação — individual e em lote —, e o "Fechar sem salvar" do Cadastro de Regras, que **tinha um modal bespoke próprio pra esse mesmo motivo** — `#ips-regracad-confirm-fechar-modal`, criado numa rodada anterior só porque `window.confirm()` mostra a URL do servidor; removido e substituído por `pidConfirm()` nesta rodada, consolidando os dois em um único componente), `ramais.js` (remover telefone externo, remover função de telefonia, deletar ausência, remover ramal, erros), `profiles.js` (excluir perfil, erro ao salvar permissão), `indicador-desempenho.js` (excluir apuração/critério/registro de percentual/departamento), `users-admin.js` (excluir departamento/usuário, ativar/desativar usuário, avisos de "não pode excluir/desativar a si mesmo"). Ação destrutiva (excluir/remover/deletar) sempre usa `{ perigoso: true }`; ações não-destrutivas (reverter, ativar/desativar) não.
- **Não migrado**: `window.prompt()` em `users-admin.js` (renomear departamento, `data-dep-edit`) — é um tipo de popup diferente (pede texto, não só confirmar/cancelar) e não tinha um componente equivalente pronto; fica pra uma rodada futura se for pedido, junto com um modal de input genérico.
- **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).
**`seed_portal.py` não reseta mais `nome` de um perfil já existente** (bug real corrigido nesta rodada, motivado pelo usuário ter renomeado o perfil "Integração e Inovação" código 8 pra "Inovação" — e criado um perfil novo "Integração", código 9): `update_or_create(codigo=..., defaults={"nome": ..., ...})` reescrevia `nome` a cada execução, revertendo qualquer renomeação feita depois pela tela de Perfis de Acesso. Trocado por `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação) + atribuição direta de `ativo`/`gerencia_permissoes`/`permissoes` a cada execução (esses continuam sendo realinhados sempre — é assim que o seed serve pra corrigir uma árvore de permissões corrompida/desatualizada, só `nome` parou de ser tocado). Mesmo espírito de "só inicializa na primeira criação" já usado pra gabriel/bruno.
## Inativar/reativar usuário (usuarios.html) ## Inativar/reativar usuário (usuarios.html)
Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação: Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação:
@ -394,7 +420,7 @@ Não há exceção pra `gerencia_permissoes`/perfil de acesso total — mesmo "I
**Observações ricas (texto + imagens embutidas)**: o campo "Observações" do modal de acesso (`#ag-form-observacoes`) é um `<div contenteditable>`, não um `<textarea>` — permite formatar texto livremente e incluir imagens **sem nenhum botão dedicado**: colar (`Ctrl+V`, evento `paste`, lido de `event.clipboardData.items`) ou arrastar um arquivo de imagem pra dentro do campo (evento `drop`, com `dragover` chamando `preventDefault()` pra permitir o drop) — as duas vias caem na mesma função `insertImageFile()` em `acessos-gerais.js`. A imagem (até **2MB**, `PID_AG_IMAGE_MAX_BYTES`, checado antes de inserir) vira uma data URI via `FileReader.readAsDataURL` e é inserida com `document.execCommand("insertImage", ...)` — sem upload de arquivo separado, fica embutida no próprio HTML salvo em `observacoes`. No caminho de `paste` o cursor já está na posição certa (o navegador só troca o clipboard, não move o foco); no de `drop`, `placeCaretAtPoint()` usa `document.caretRangeFromPoint`/`caretPositionFromPoint` (conforme suporte do browser) pra posicionar o cursor exatamente onde o arquivo foi solto antes de inserir. **Observações ricas (texto + imagens embutidas)**: o campo "Observações" do modal de acesso (`#ag-form-observacoes`) é um `<div contenteditable>`, não um `<textarea>` — permite formatar texto livremente e incluir imagens **sem nenhum botão dedicado**: colar (`Ctrl+V`, evento `paste`, lido de `event.clipboardData.items`) ou arrastar um arquivo de imagem pra dentro do campo (evento `drop`, com `dragover` chamando `preventDefault()` pra permitir o drop) — as duas vias caem na mesma função `insertImageFile()` em `acessos-gerais.js`. A imagem (até **2MB**, `PID_AG_IMAGE_MAX_BYTES`, checado antes de inserir) vira uma data URI via `FileReader.readAsDataURL` e é inserida com `document.execCommand("insertImage", ...)` — sem upload de arquivo separado, fica embutida no próprio HTML salvo em `observacoes`. No caminho de `paste` o cursor já está na posição certa (o navegador só troca o clipboard, não move o foco); no de `drop`, `placeCaretAtPoint()` usa `document.caretRangeFromPoint`/`caretPositionFromPoint` (conforme suporte do browser) pra posicionar o cursor exatamente onde o arquivo foi solto antes de inserir.
- **Sanitização (`nh3`)**: como esse HTML é gerado por quem tem `acessos-gerais-editar` mas renderizado via `innerHTML` pra qualquer um com `acessos-gerais-visualizar`, ele passa por um allowlist estrito no backend antes de salvar — `AcessoGeralSerializer.validate_observacoes()` roda `nh3.clean()` permitindo apenas tags de texto básicas + `<img>` (`ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS`/`_ALLOWED_ATTRS`/`_ALLOWED_SCHEMES` no topo de `serializers.py`) — **sem `<a>`/`<script>`/atributos de evento** (`onerror` etc. são descartados por não estarem na allowlist de atributos). `url_schemes` inclui `"data"` de propósito, já que as imagens embutidas são `data:image/...;base64,...`, não URLs externas. Isso significa que o campo é reprocessado no servidor mesmo que o cliente já não deixe inserir nada além de texto/imagem pela UI — defesa em profundidade contra alguém montando o payload na mão. `nh3` é o binding Python da lib Rust "ammonia" (`requirements.txt`) — foi escolhido no lugar do `bleach` (usado numa primeira versão desta funcionalidade) porque o `bleach` está oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026; `nh3` tem API quase idêntica (`clean(html, tags=set[...], attributes=dict[...], url_schemes=set[...])`, allowlist do mesmo jeito) e é o substituto recomendado pelos próprios mantenedores do bleach. - **Sanitização (`nh3`)**: como esse HTML é gerado por quem tem `acessos-gerais-editar` mas renderizado via `innerHTML` pra qualquer um com `acessos-gerais-visualizar`, ele passa por um allowlist estrito no backend antes de salvar — `AcessoGeralSerializer.validate_observacoes()` roda `nh3.clean()` permitindo apenas tags de texto básicas + `<img>` (`RICHTEXT_ALLOWED_TAGS`/`_ATTRS`/`_SCHEMES` no topo de `serializers.py` — generalizadas nessas constantes desde que o texto de "Mais informações" de uma aplicação passou a reaproveitar o mesmo allowlist, ver "Ajuda de aplicação" abaixo) — **sem `<a>`/`<script>`/atributos de evento** (`onerror` etc. são descartados por não estarem na allowlist de atributos). `url_schemes` inclui `"data"` de propósito, já que as imagens embutidas são `data:image/...;base64,...`, não URLs externas. Isso significa que o campo é reprocessado no servidor mesmo que o cliente já não deixe inserir nada além de texto/imagem pela UI — defesa em profundidade contra alguém montando o payload na mão. `nh3` é o binding Python da lib Rust "ammonia" (`requirements.txt`) — foi escolhido no lugar do `bleach` (usado numa primeira versão desta funcionalidade) porque o `bleach` está oficialmente sem manutenção desde 2023 e parou de receber releases (inclusive de segurança) em 2026; `nh3` tem API quase idêntica (`clean(html, tags=set[...], attributes=dict[...], url_schemes=set[...])`, allowlist do mesmo jeito) e é o substituto recomendado pelos próprios mantenedores do bleach.
- Ao carregar um acesso existente pra editar, `formObservacoes.innerHTML = acesso.observacoes` repopula o editor com o HTML já sanitizado (imagens inclusas); salvar lê `formObservacoes.innerHTML` (função `observacoesValue()`, que retorna string vazia se não houver nem texto nem `<img>`, evitando salvar lixo tipo um `<br>` solto de um editor "vazio"). - Ao carregar um acesso existente pra editar, `formObservacoes.innerHTML = acesso.observacoes` repopula o editor com o HTML já sanitizado (imagens inclusas); salvar lê `formObservacoes.innerHTML` (função `observacoesValue()`, que retorna string vazia se não houver nem texto nem `<img>`, evitando salvar lixo tipo um `<br>` solto de um editor "vazio").
**Popup de detalhes** (`#ag-view-modal`, `acessos-gerais.js`): mostra nome, URL (link clicável), usuário, senha e observações — cada campo (`.ag-view-field`) só aparece se tiver valor (`hidden` quando vazio). A senha começa mascarada (`"••••••••"`, com o valor real guardado em `viewSenha.dataset.value`) e um botão de olho alterna pra o valor real — a máscara é feita trocando o próprio `textContent`, não com CSS (`-webkit-text-security` não é suportado em todos os browsers e deixaria a senha real exposta no DOM seletável mesmo "mascarada" visualmente nesses casos). As observações são renderizadas via `innerHTML` (não `textContent`, ao contrário dos outros campos) já que podem conter as imagens embutidas — seguro porque o HTML já veio sanitizado do backend; o container é uma `<div class="ag-view-observacoes">` (não `<p>`, que não pode conter `<img>`/`<div>` sem gerar HTML inválido). Com `acessos-gerais-editar`, o popup também mostra "Editar"/"Excluir"; "Editar" fecha o popup e abre o mesmo modal de formulário (`#ag-form-modal`) usado por "Adicionar Acesso", pré-preenchido. **Popup de detalhes** (`#ag-view-modal`, `acessos-gerais.js`): mostra nome, URL (link clicável), usuário, senha e observações — cada campo (`.ag-view-field`) só aparece se tiver valor (`hidden` quando vazio). A senha começa mascarada (`"••••••••"`, com o valor real guardado em `viewSenha.dataset.value`) e um botão de olho alterna pra o valor real — a máscara é feita trocando o próprio `textContent`, não com CSS (`-webkit-text-security` não é suportado em todos os browsers e deixaria a senha real exposta no DOM seletável mesmo "mascarada" visualmente nesses casos). As observações são renderizadas via `innerHTML` (não `textContent`, ao contrário dos outros campos) já que podem conter as imagens embutidas — seguro porque o HTML já veio sanitizado do backend; o container é uma `<div class="ag-view-observacoes">` (não `<p>`, que não pode conter `<img>`/`<div>` sem gerar HTML inválido). Com `acessos-gerais-editar`, o popup também mostra "Editar"/"Excluir"; "Editar" fecha o popup e abre o mesmo modal de formulário (`#ag-form-modal`) usado por "Adicionar Acesso", pré-preenchido.
@ -536,14 +562,14 @@ portal_api/planos_saude/
├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome) ├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome)
├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome) ├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome)
├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) ├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome)
└── bradesco/odonto_mensalidade.py Bradesco Dental/Bradessaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo) └── bradesco/odonto_mensalidade.py Bradesco Dental/Bradesaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo)
``` ```
Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo.
**PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar.
**Bradesco Dental / "Bradessaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. **Bradesco Dental / "Bradesaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`<família>/00` = titular, `<família>/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde.
**Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`. **Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`.
@ -668,7 +694,7 @@ Cobre regras de custeio negociadas com uma empresa específica que não cabem no
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). | | `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`). | | `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`. | | `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`) e `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver seção "Liderança" — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell). | | `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 seção "Liderança" — 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`. | | `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`. | | `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. | | `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. |

View File

@ -0,0 +1,8 @@
CODIGOEMPRESA;NOMEFUNC;CPFFUNC;CODIGOOUTEMP;DATAINICIAL;NOMEDEPENDENTE;CPFDEPENDENTE;VALOREMPRESA;VALOR;DESCRICAO
626;BRUNA ALMEIDA BEVILAQUA;123.061.329-33;3755;01/07/2026;;;0;0;
626;JAMAL ABDEL FAYYAT;136.100.328-63;3755;01/07/2026;ELLEN LUCE RESENDE CASTRO FAYYAT;988.874.326-00;0;0;
626;JAMAL ABDEL FAYYAT;136.100.328-63;3755;01/07/2026;;;0;0;
626;JAMAL ABDEL FAYYAT;136.100.328-63;3755;01/07/2026;TAREK CASTRO FAYYAT;077.252.129-81;0;0;
626;NEURI DUARTE GARCIA;662.104.709-63;3755;01/05/2026;;;0;0;
626;VANESSA LOPES GOMES;007.998.369-31;3755;01/05/2026;;;0;0;
626;ZAINAB MARIAM HAGE GONZALEZ;014.261.989-22;3755;01/06/2026;;;0;0;
1 CODIGOEMPRESA NOMEFUNC CPFFUNC CODIGOOUTEMP DATAINICIAL NOMEDEPENDENTE CPFDEPENDENTE VALOREMPRESA VALOR DESCRICAO
2 626 BRUNA ALMEIDA BEVILAQUA 123.061.329-33 3755 01/07/2026 0 0
3 626 JAMAL ABDEL FAYYAT 136.100.328-63 3755 01/07/2026 ELLEN LUCE RESENDE CASTRO FAYYAT 988.874.326-00 0 0
4 626 JAMAL ABDEL FAYYAT 136.100.328-63 3755 01/07/2026 0 0
5 626 JAMAL ABDEL FAYYAT 136.100.328-63 3755 01/07/2026 TAREK CASTRO FAYYAT 077.252.129-81 0 0
6 626 NEURI DUARTE GARCIA 662.104.709-63 3755 01/05/2026 0 0
7 626 VANESSA LOPES GOMES 007.998.369-31 3755 01/05/2026 0 0
8 626 ZAINAB MARIAM HAGE GONZALEZ 014.261.989-22 3755 01/06/2026 0 0

View File

@ -4,6 +4,7 @@ from django.contrib.auth.admin import UserAdmin
from .models import ( from .models import (
AcessoGeral, AcessoGeral,
AcessoGeralSecao, AcessoGeralSecao,
AjudaAplicacao,
CompromissoAgenda, CompromissoAgenda,
Departamento, Departamento,
EmpresaQuestor, EmpresaQuestor,
@ -78,6 +79,12 @@ class NotificacaoDispensadaAdmin(admin.ModelAdmin):
search_fields = ("notif_id",) search_fields = ("notif_id",)
@admin.register(AjudaAplicacao)
class AjudaAplicacaoAdmin(admin.ModelAdmin):
list_display = ("app_key", "atualizado_em", "atualizado_por")
search_fields = ("app_key",)
@admin.register(LinkFerramenta) @admin.register(LinkFerramenta)
class LinkFerramentaAdmin(admin.ModelAdmin): class LinkFerramentaAdmin(admin.ModelAdmin):
list_display = ("nome", "url", "ordem") list_display = ("nome", "url", "ordem")

View File

@ -166,15 +166,19 @@ class Command(BaseCommand):
# todo perfil) — sem este override, todo perfil nasceria podendo criar # todo perfil) — sem este override, todo perfil nasceria podendo criar
# evento de departamento/todos e cadastrar categoria de evento. # evento de departamento/todos e cadastrar categoria de evento.
permissoes["calendario-individual"]["apps"]["calendario-individual-criar-evento"] = False permissoes["calendario-individual"]["apps"]["calendario-individual-criar-evento"] = False
perfil, criado = PerfilAcesso.objects.update_or_create( # `nome` só é gravado na criação — reexecuções não devem reverter uma
codigo=dado["codigo"], # renomeação feita depois pela própria tela de Perfis de Acesso (mesmo
defaults={ # espírito de "só inicializa na primeira criação" já usado pra
"nome": dado["nome"], # gabriel/bruno logo abaixo). `ativo`/`gerencia_permissoes`/`permissoes`
"ativo": True, # continuam sendo realinhados a cada execução — é assim que o seed serve
"gerencia_permissoes": dado["gerencia_permissoes"], # pra corrigir uma árvore de permissões corrompida/desatualizada.
"permissoes": permissoes, perfil, criado = PerfilAcesso.objects.get_or_create(
}, codigo=dado["codigo"], defaults={"nome": dado["nome"]}
) )
perfil.ativo = True
perfil.gerencia_permissoes = dado["gerencia_permissoes"]
perfil.permissoes = permissoes
perfil.save()
acao = "criado" if criado else "atualizado" acao = "criado" if criado else "atualizado"
self.stdout.write(f"Perfil {acao}: {perfil.nome} (código {perfil.codigo})") self.stdout.write(f"Perfil {acao}: {perfil.nome} (código {perfil.codigo})")

View File

@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-08-24 12:23
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0045_importacaoplanosaude_competencia_and_more'),
]
operations = [
migrations.CreateModel(
name='AjudaAplicacao',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('app_key', models.CharField(max_length=100, unique=True, verbose_name='Chave da aplicação')),
('texto', models.TextField(blank=True, verbose_name='Texto')),
('atualizado_em', models.DateTimeField(auto_now=True, verbose_name='Atualizado em')),
('atualizado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Ajuda de aplicação',
'verbose_name_plural': 'Ajudas de aplicação',
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 6.0.7 on 2026-08-24 12:48
import portal_api.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0046_ajudaaplicacao'),
]
operations = [
migrations.AlterField(
model_name='ajudaaplicacao',
name='texto',
field=models.TextField(blank=True, validators=[portal_api.models.validar_tamanho_texto_ajuda_aplicacao], verbose_name='Texto'),
),
]

View File

@ -14,12 +14,20 @@ from .custo_contratacao.calculo import ParametrosFiscais
LINK_FERRAMENTA_ICONE_MAX_BYTES = 2 * 1024 * 1024 LINK_FERRAMENTA_ICONE_MAX_BYTES = 2 * 1024 * 1024
ACESSO_GERAL_OBSERVACOES_MAX_CHARS = 2_000_000 ACESSO_GERAL_OBSERVACOES_MAX_CHARS = 2_000_000
AJUDA_APLICACAO_TEXTO_MAX_CHARS = 2_000_000
PLANO_SAUDE_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024 PLANO_SAUDE_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
COMPROMISSO_HORARIO_COMERCIAL_INICIO = time(8, 0) COMPROMISSO_HORARIO_COMERCIAL_INICIO = time(8, 0)
COMPROMISSO_HORARIO_COMERCIAL_FIM = time(18, 0) COMPROMISSO_HORARIO_COMERCIAL_FIM = time(18, 0)
COMPROMISSO_LEMBRETE_HORAS = {"1h": 1, "2h": 2, "4h": 4, "24h": 24} COMPROMISSO_LEMBRETE_HORAS = {"1h": 1, "2h": 2, "4h": 4, "24h": 24}
# Nome do perfil autorizado a editar o texto de "Mais informações" das
# aplicações (`AjudaAplicacao`, ver abaixo) — match de nome fixo, mesmo
# padrão já usado pro selo "Restrito" de Relatórios Gerenciais (nome ===
# "Diretoria"), não uma flag na árvore de permissões. Sobrevive a uma
# renomeação de perfil só se este valor for atualizado junto.
PERFIL_INOVACAO_NOME = "Inovação"
def _dia_util(dia: date) -> bool: def _dia_util(dia: date) -> bool:
return dia.weekday() < 5 return dia.weekday() < 5
@ -51,6 +59,11 @@ def validar_tamanho_observacoes_acesso(valor: str) -> None:
raise ValidationError("As observações (com imagens embutidas) ficaram grandes demais.") raise ValidationError("As observações (com imagens embutidas) ficaram grandes demais.")
def validar_tamanho_texto_ajuda_aplicacao(valor: str) -> None:
if len(valor) > AJUDA_APLICACAO_TEXTO_MAX_CHARS:
raise ValidationError("O texto (com imagens embutidas) ficou grande demais.")
def validar_cor_categoria_evento(valor: str) -> None: def validar_cor_categoria_evento(valor: str) -> None:
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", valor): if not re.fullmatch(r"#[0-9A-Fa-f]{6}", valor):
raise ValidationError("A cor deve estar no formato hexadecimal, ex.: #7c4dff.") raise ValidationError("A cor deve estar no formato hexadecimal, ex.: #7c4dff.")
@ -173,6 +186,11 @@ class Usuario(AbstractUser):
Ferramentas), não só "o módulo está habilitado".""" Ferramentas), não só "o módulo está habilitado"."""
return any(p.permissoes.get(module_key, {}).get("apps", {}).get(app_key) for p in self.perfis.all()) return any(p.permissoes.get(module_key, {}).get("apps", {}).get(app_key) for p in self.perfis.all())
def eh_perfil_inovacao(self) -> bool:
"""Só quem tem o perfil `PERFIL_INOVACAO_NOME` vinculado pode editar
o texto de "Mais informações" de uma aplicação (`AjudaAplicacao`)."""
return self.perfis.filter(nome=PERFIL_INOVACAO_NOME).exists()
class CompromissoAgenda(models.Model): class CompromissoAgenda(models.Model):
VISIBILIDADE_SOMENTE_EU = "somente_eu" VISIBILIDADE_SOMENTE_EU = "somente_eu"
@ -308,6 +326,38 @@ class NotificacaoDispensada(models.Model):
return f"{self.usuario} → {self.notif_id}" return f"{self.usuario} → {self.notif_id}"
class AjudaAplicacao(models.Model):
"""Texto de "Mais informações" mostrado no botão de interrogação ao lado
do nome de uma aplicação — chave natural (`app_key`, mesma ideia de
`app_id`/`notif_id` acima), sem FK pra `Usuario`: é um texto
compartilhado, igual pra quem quer que abra o modal. Visualizar é
liberado a qualquer autenticado; editar é restrito a quem tem o perfil
`PERFIL_INOVACAO_NOME` vinculado (`Usuario.eh_perfil_inovacao()`), uma
regra fixa que não aparece na árvore de permissões de Perfis de Acesso.
`texto` aceita imagens embutidas (mesmo mecanismo de `AcessoGeral.
observacoes` — `<div contenteditable>` no cliente, sanitizado no servidor
via `nh3` antes de salvar, ver `RICHTEXT_ALLOWED_TAGS` em serializers.py)."""
app_key = models.CharField("Chave da aplicação", max_length=100, unique=True)
texto = models.TextField("Texto", blank=True, validators=[validar_tamanho_texto_ajuda_aplicacao])
atualizado_em = models.DateTimeField("Atualizado em", auto_now=True)
atualizado_por = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
class Meta:
verbose_name = "Ajuda de aplicação"
verbose_name_plural = "Ajudas de aplicação"
def __str__(self) -> str:
return self.app_key
@classmethod
def para_app(cls, app_key: str) -> "AjudaAplicacao":
obj, _ = cls.objects.get_or_create(app_key=app_key)
return obj
class LinkFerramenta(models.Model): class LinkFerramenta(models.Model):
"""Cartões da tela Links & Ferramentas — lista compartilhada (não por usuário). """Cartões da tela Links & Ferramentas — lista compartilhada (não por usuário).
Ordenação editável só por quem tem `apps.editar=True` em `links-ferramentas` num Ordenação editável só por quem tem `apps.editar=True` em `links-ferramentas` num

View File

@ -59,7 +59,7 @@ OPERADORAS = {
}, },
"bradesco_dental_odonto_mensalidade": { "bradesco_dental_odonto_mensalidade": {
"codigo_operadora": "3759", "codigo_operadora": "3759",
"nome": "Bradessaude Odonto", "nome": "Bradesaude Odonto",
"parser": BradescoDentalOdontoMensalidade, "parser": BradescoDentalOdontoMensalidade,
}, },
} }

View File

@ -11,6 +11,7 @@ from .indicadores import calculo as indicadores_calculo
from .models import ( from .models import (
AcessoGeral, AcessoGeral,
AcessoGeralSecao, AcessoGeralSecao,
AjudaAplicacao,
CategoriaEvento, CategoriaEvento,
CompromissoAgenda, CompromissoAgenda,
Departamento, Departamento,
@ -46,18 +47,20 @@ from .planos_saude.leiaute_sistema import formata_valor_br, parse_valor_br
from .planos_saude.pipeline import CUSTEIOS_VALIDOS, OPERADORAS, TIPOS_LANCAMENTO_VALIDOS, TIPOS_PESSOA_VALIDOS from .planos_saude.pipeline import CUSTEIOS_VALIDOS, OPERADORAS, TIPOS_LANCAMENTO_VALIDOS, TIPOS_PESSOA_VALIDOS
from .planos_saude.regras_empresa import REGRAS_EMPRESA from .planos_saude.regras_empresa import REGRAS_EMPRESA
# Observações de Acesso Geral aceitam imagens embutidas (ver "Acessos Gerais" no # Allowlist compartilhada por todo campo de texto rico com imagens embutidas
# CLAUDE.md) — o conteúdo vem de um <div contenteditable> no cliente e é # (Observações de Acesso Geral, ver "Acessos Gerais" no CLAUDE.md; texto de
# renderizado via innerHTML pra qualquer usuário com acesso de visualização, então # "Mais informações" de uma aplicação, ver "Ajuda de aplicação") — o conteúdo
# passa por um allowlist estrito (nh3 — binding Python da lib Rust "ammonia"; # vem de um <div contenteditable> no cliente e é renderizado via innerHTML pra
# usado no lugar do bleach, que está sem manutenção desde 2023 e parou de # qualquer usuário com acesso de visualização, então passa por um allowlist
# receber releases, inclusive de segurança) antes de salvar: só marcação de # estrito (nh3 — binding Python da lib Rust "ammonia"; usado no lugar do
# texto básica + <img>, nada de <script>/<a>/atributos de evento. `data` entra # bleach, que está sem manutenção desde 2023 e parou de receber releases,
# nos esquemas de URL aceitos porque as imagens inseridas viajam como data URI # inclusive de segurança) antes de salvar: só marcação de texto básica +
# (sem upload de arquivo separado), não como URL externa. # <img>, nada de <script>/<a>/atributos de evento. `data` entra nos esquemas
ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS = {"p", "br", "div", "b", "strong", "i", "em", "u", "ul", "ol", "li", "img"} # de URL aceitos porque as imagens inseridas viajam como data URI (sem upload
ACESSO_GERAL_OBSERVACOES_ALLOWED_ATTRS = {"img": {"src", "alt"}} # de arquivo separado), não como URL externa.
ACESSO_GERAL_OBSERVACOES_ALLOWED_SCHEMES = {"http", "https", "data"} RICHTEXT_ALLOWED_TAGS = {"p", "br", "div", "b", "strong", "i", "em", "u", "ul", "ol", "li", "img"}
RICHTEXT_ALLOWED_ATTRS = {"img": {"src", "alt"}}
RICHTEXT_ALLOWED_SCHEMES = {"http", "https", "data"}
class DepartamentoSerializer(serializers.ModelSerializer): class DepartamentoSerializer(serializers.ModelSerializer):
@ -320,6 +323,26 @@ class NotificacaoDispensadaSerializer(serializers.ModelSerializer):
fields = ["id", "notif_id"] fields = ["id", "notif_id"]
class AjudaAplicacaoSerializer(serializers.ModelSerializer):
atualizado_por_nome = serializers.SerializerMethodField()
class Meta:
model = AjudaAplicacao
fields = ["app_key", "texto", "atualizado_em", "atualizado_por_nome"]
read_only_fields = ["app_key", "atualizado_em", "atualizado_por_nome"]
def get_atualizado_por_nome(self, obj: AjudaAplicacao) -> str | None:
return obj.atualizado_por.nome if obj.atualizado_por else None
def validate_texto(self, value: str) -> str:
return nh3.clean(
value,
tags=RICHTEXT_ALLOWED_TAGS,
attributes=RICHTEXT_ALLOWED_ATTRS,
url_schemes=RICHTEXT_ALLOWED_SCHEMES,
)
class WidgetUsuarioSerializer(serializers.ModelSerializer): class WidgetUsuarioSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = WidgetUsuario model = WidgetUsuario
@ -384,9 +407,9 @@ class AcessoGeralSerializer(serializers.ModelSerializer):
def validate_observacoes(self, value: str) -> str: def validate_observacoes(self, value: str) -> str:
return nh3.clean( return nh3.clean(
value, value,
tags=ACESSO_GERAL_OBSERVACOES_ALLOWED_TAGS, tags=RICHTEXT_ALLOWED_TAGS,
attributes=ACESSO_GERAL_OBSERVACOES_ALLOWED_ATTRS, attributes=RICHTEXT_ALLOWED_ATTRS,
url_schemes=ACESSO_GERAL_OBSERVACOES_ALLOWED_SCHEMES, url_schemes=RICHTEXT_ALLOWED_SCHEMES,
) )

View File

@ -85,6 +85,7 @@ urlpatterns = [
views.parametros_fiscais_custo_contratacao_view, views.parametros_fiscais_custo_contratacao_view,
name="parametros-fiscais-custo-contratacao", name="parametros-fiscais-custo-contratacao",
), ),
path("ajuda-aplicacoes/<str:app_key>/", views.ajuda_aplicacao_view, name="ajuda-aplicacao"),
path("usuarios-resumo/", views.usuarios_resumo_view, name="usuarios-resumo"), path("usuarios-resumo/", views.usuarios_resumo_view, name="usuarios-resumo"),
path("departamentos-resumo/", views.departamentos_resumo_view, name="departamentos-resumo"), path("departamentos-resumo/", views.departamentos_resumo_view, name="departamentos-resumo"),
path("", include(router.urls)), path("", include(router.urls)),

View File

@ -31,6 +31,7 @@ from .custo_contratacao.pdf import gera_pdf_simulacao
from .models import ( from .models import (
AcessoGeral, AcessoGeral,
AcessoGeralSecao, AcessoGeralSecao,
AjudaAplicacao,
CategoriaEvento, CategoriaEvento,
CompromissoAgenda, CompromissoAgenda,
Departamento, Departamento,
@ -76,6 +77,7 @@ from .planos_saude.regras_empresa import RegraEmpresaIncompativelError
from .serializers import ( from .serializers import (
AcessoGeralSecaoSerializer, AcessoGeralSecaoSerializer,
AcessoGeralSerializer, AcessoGeralSerializer,
AjudaAplicacaoSerializer,
CategoriaEventoSerializer, CategoriaEventoSerializer,
CompromissoAgendaSerializer, CompromissoAgendaSerializer,
DepartamentoSerializer, DepartamentoSerializer,
@ -195,6 +197,7 @@ def me_view(request: Request) -> Response:
"perfis": PerfilResumoSerializer(perfis, many=True).data, "perfis": PerfilResumoSerializer(perfis, many=True).data,
"departamentos": DepartamentoSerializer(usuario.departamentos.all(), many=True).data, "departamentos": DepartamentoSerializer(usuario.departamentos.all(), many=True).data,
"gerencia_permissoes": usuario.gerencia_permissoes(), "gerencia_permissoes": usuario.gerencia_permissoes(),
"eh_perfil_inovacao": usuario.eh_perfil_inovacao(),
"permissoes_efetivas": permissoes_efetivas(perfis), "permissoes_efetivas": permissoes_efetivas(perfis),
"lideranca": usuario.lideranca, "lideranca": usuario.lideranca,
"liderados": UsuarioResumoSerializer(usuario.liderados.all(), many=True).data, "liderados": UsuarioResumoSerializer(usuario.liderados.all(), many=True).data,
@ -323,6 +326,27 @@ def parametros_fiscais_custo_contratacao_view(request: Request) -> Response:
return Response(serializer.data) return Response(serializer.data)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def ajuda_aplicacao_view(request: Request, app_key: str) -> Response:
"""Texto de "Mais informações" de uma aplicação (botão de interrogação ao
lado do nome, ver CLAUDE.md) — visualização livre a qualquer autenticado;
edição restrita a quem tem o perfil `PERFIL_INOVACAO_NOME` vinculado,
checagem fixa (`Usuario.eh_perfil_inovacao()`), não uma flag na árvore de
permissões."""
ajuda = AjudaAplicacao.para_app(app_key)
if request.method == "GET":
return Response(AjudaAplicacaoSerializer(ajuda).data)
if not request.user.eh_perfil_inovacao():
raise PermissionDenied("Somente o perfil Inovação pode editar este texto.")
serializer = AjudaAplicacaoSerializer(ajuda, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save(atualizado_por=request.user)
return Response(serializer.data)
class PerfilAcessoViewSet(viewsets.ModelViewSet): class PerfilAcessoViewSet(viewsets.ModelViewSet):
queryset = PerfilAcesso.objects.all().order_by("codigo") queryset = PerfilAcesso.objects.all().order_by("codigo")
serializer_class = PerfilAcessoSerializer serializer_class = PerfilAcessoSerializer

View File

@ -18,6 +18,62 @@
transform: scale(0.92); transform: scale(0.92);
} }
/* Botão "?" reutilizável ao lado do nome de uma aplicação (ver
static/js/ajuda-aplicacao.js) — tooltip "Mais informações" ao passar o
mouse, CSS puro (sem JS de posicionamento) porque a posição relativa ao
próprio botão nunca varia, ao contrário de .sidebar-tooltip acima. */
.info-tooltip {
position: relative;
display: inline-flex;
}
.info-tooltip__trigger {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-secondary);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.info-tooltip__trigger:hover,
.info-tooltip__trigger:focus-visible {
background: var(--bg-surface-raised);
color: var(--accent);
border-color: var(--accent);
}
.info-tooltip__text {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
background: var(--bg-surface-raised);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
font-size: 0.75rem;
box-shadow: var(--shadow-sm);
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
z-index: 20;
}
.info-tooltip__trigger:hover + .info-tooltip__text,
.info-tooltip__trigger:focus-visible + .info-tooltip__text {
opacity: 1;
}
.btn-ghost { .btn-ghost {
display: flex; display: flex;
align-items: center; align-items: center;
@ -102,6 +158,13 @@
animation: pidFadeIn 120ms ease; animation: pidFadeIn 120ms ease;
} }
/* Empilha um modal por cima de outro já aberto — usado só pelo modal de
confirmação genérico (static/js/confirm-modal.js), que precisa aparecer
por cima de qualquer modal em edição sem fechá-lo. */
.modal-overlay--top {
z-index: 150;
}
.modal-card { .modal-card {
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
@ -305,6 +368,69 @@
justify-content: space-between; justify-content: space-between;
} }
/* Modal de "Mais informações" (ver static/js/ajuda-aplicacao.js) — único,
criado sob demanda e reaproveitado por todas as aplicações da página. */
.ajuda-modal__card {
max-width: 620px;
}
.ajuda-modal__titulo {
font-size: 1.05rem;
font-weight: 700;
}
.ajuda-modal__texto {
white-space: pre-wrap;
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.6;
max-height: 55vh;
overflow-y: auto;
}
.ajuda-modal__texto img {
max-width: 100%;
border-radius: var(--radius-sm);
margin: var(--space-2) 0;
}
/* Editor rico (contenteditable + imagens embutidas via colar/arrastar) —
mesmo mecanismo de .ag-richtext (acessos-gerais.js), reimplementado aqui
porque este modal pode aparecer em qualquer página, não só
acessos-gerais.html (que é onde .ag-richtext mora, escopado). */
.ajuda-modal__editor {
min-height: 220px;
max-height: 55vh;
overflow-y: auto;
padding: var(--space-3);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
background: var(--bg-canvas);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
}
.ajuda-modal__editor:focus {
border-color: var(--accent);
}
.ajuda-modal__editor:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
}
.ajuda-modal__editor img {
max-width: 100%;
border-radius: var(--radius-sm);
margin: var(--space-2) 0;
}
.ajuda-modal__editor-hint {
font-size: 0.76rem;
color: var(--text-muted);
}
.checklist-box { .checklist-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -12,6 +12,12 @@
font-weight: 700; font-weight: 700;
} }
.ips-title-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.ips-header-actions { .ips-header-actions {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);

View File

@ -459,7 +459,7 @@ document.addEventListener("DOMContentLoaded", async () => {
viewDeleteBtn.addEventListener("click", async () => { viewDeleteBtn.addEventListener("click", async () => {
const acesso = acessos.find((a) => a.id === viewingAcessoId); const acesso = acessos.find((a) => a.id === viewingAcessoId);
if (!acesso) return; if (!acesso) return;
if (!window.confirm(`Excluir o acesso "${acesso.nome}"?`)) return; if (!(await pidConfirm(`Excluir o acesso "${acesso.nome}"?`, { perigoso: true }))) return;
await pidDeleteAcessoGeral(acesso.id); await pidDeleteAcessoGeral(acesso.id);
closeViewModal(); closeViewModal();
await refresh(); await refresh();
@ -486,7 +486,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (secaoDeleteBtn) { if (secaoDeleteBtn) {
const id = Number(secaoDeleteBtn.getAttribute("data-secao-delete")); const id = Number(secaoDeleteBtn.getAttribute("data-secao-delete"));
const secao = secoes.find((s) => s.id === id); const secao = secoes.find((s) => s.id === id);
if (secao && window.confirm(`Excluir a seção "${secao.nome}" e todos os seus acessos?`)) { if (secao && (await pidConfirm(`Excluir a seção "${secao.nome}" e todos os seus acessos?`, { perigoso: true }))) {
await pidDeleteAcessoGeralSecao(id); await pidDeleteAcessoGeralSecao(id);
await refresh(); await refresh();
} }

View File

@ -0,0 +1,219 @@
// Botão "?" reutilizável ao lado do nome de uma aplicação — ao passar o
// mouse mostra "Mais informações" (ver .info-tooltip em components.css); ao
// clicar, abre um modal com o texto cadastrado em GET /api/ajuda-aplicacoes/{app_key}/.
// Visualizar é livre a qualquer autenticado; editar é restrito a quem tem o
// perfil Inovação (me.eh_perfil_inovacao, já calculado no servidor) — regra
// fixa, não uma flag na árvore de permissões (ver CLAUDE.md).
//
// Um único modal é criado sob demanda e reaproveitado por todos os botões da
// página — só um pode estar aberto por vez. Para adicionar o botão a uma
// aplicação nova: no HTML, ao lado do nome da aplicação, `pidCriarBotaoAjuda(
// "<id do botão>", "<app_key>", "<nome de exibição>")` na página que a usa.
//
// O texto aceita imagens embutidas (colar/arrastar), mesmo mecanismo das
// Observações de Acessos Gerais (`.ag-richtext` em acessos-gerais.js) — o
// HTML digitado aqui só é sanitizado no servidor (`AjudaAplicacaoSerializer.
// validate_texto`), então o editor em si não precisa filtrar nada.
const PID_AJUDA_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
function pidAjudaAplicacaoModal() {
let modal = document.getElementById("ajuda-aplicacao-modal");
if (modal) return modal;
modal = document.createElement("div");
modal.className = "modal-overlay";
modal.id = "ajuda-aplicacao-modal";
modal.hidden = true;
modal.dataset.editing = "false";
modal.innerHTML = `
<div class="modal-card ajuda-modal__card">
<h2 class="ajuda-modal__titulo"></h2>
<div class="ajuda-modal__texto"></div>
<div class="ajuda-modal__editor" contenteditable="true" hidden data-placeholder="Escreva aqui — cole (Ctrl+V) ou arraste uma imagem para incluí-la no texto"></div>
<p class="ajuda-modal__editor-hint" hidden>Cole (Ctrl+V) ou arraste uma imagem para incluí-la no texto.</p>
<p class="modal-error" hidden></p>
<div class="modal-actions">
<button type="button" class="btn-outline ajuda-modal__editar-btn" hidden>Editar</button>
<button type="button" class="btn-outline ajuda-modal__cancelar-btn" hidden>Cancelar</button>
<button type="button" class="btn-solid ajuda-modal__salvar-btn" hidden>Salvar</button>
<button type="button" class="btn-outline ajuda-modal__fechar-btn">Fechar</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener("click", (event) => {
if (event.target === modal) pidFecharAjudaAplicacaoModal();
});
modal.querySelector(".ajuda-modal__fechar-btn").addEventListener("click", pidFecharAjudaAplicacaoModal);
const editor = modal.querySelector(".ajuda-modal__editor");
const erro = modal.querySelector(".modal-error");
function placeCaretAtPoint(x, y) {
const selection = window.getSelection();
if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y);
if (range) {
selection.removeAllRanges();
selection.addRange(range);
}
} else if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y);
if (pos) {
const range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
function insertImageFile(file, atPoint) {
if (!file || !file.type.startsWith("image/")) return;
if (file.size > PID_AJUDA_IMAGE_MAX_BYTES) {
erro.textContent = "A imagem deve ter no máximo 2MB.";
erro.hidden = false;
return;
}
erro.hidden = true;
const reader = new FileReader();
reader.onload = () => {
editor.focus();
if (atPoint) placeCaretAtPoint(atPoint.x, atPoint.y);
document.execCommand("insertImage", false, reader.result);
};
reader.readAsDataURL(file);
}
editor.addEventListener("paste", (event) => {
const items = Array.from((event.clipboardData && event.clipboardData.items) || []);
const imageItem = items.find((item) => item.type.startsWith("image/"));
if (!imageItem) return;
event.preventDefault();
insertImageFile(imageItem.getAsFile());
});
editor.addEventListener("dragover", (event) => {
event.preventDefault();
});
editor.addEventListener("drop", (event) => {
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
if (!file || !file.type.startsWith("image/")) return;
event.preventDefault();
insertImageFile(file, { x: event.clientX, y: event.clientY });
});
return modal;
}
async function pidFecharAjudaAplicacaoModal() {
const modal = document.getElementById("ajuda-aplicacao-modal");
if (!modal) return;
if (modal.dataset.editing === "true") {
const confirmado = await pidConfirm("Sair sem salvar as alterações?", { perigoso: true });
if (!confirmado) return;
}
modal.dataset.editing = "false";
modal.hidden = true;
}
function pidAjudaEditorValue(editor) {
const hasImage = editor.querySelector("img") !== null;
const hasText = editor.textContent.trim() !== "";
return hasImage || hasText ? editor.innerHTML : "";
}
async function pidAbrirAjudaAplicacaoModal(appKey, tituloApp) {
const modal = pidAjudaAplicacaoModal();
const titulo = modal.querySelector(".ajuda-modal__titulo");
const textoEl = modal.querySelector(".ajuda-modal__texto");
const editor = modal.querySelector(".ajuda-modal__editor");
const editorHint = modal.querySelector(".ajuda-modal__editor-hint");
const erro = modal.querySelector(".modal-error");
const editarBtn = modal.querySelector(".ajuda-modal__editar-btn");
const cancelarBtn = modal.querySelector(".ajuda-modal__cancelar-btn");
const salvarBtn = modal.querySelector(".ajuda-modal__salvar-btn");
modal.dataset.editing = "false";
titulo.textContent = `Mais informações — ${tituloApp}`;
textoEl.textContent = "Carregando...";
textoEl.hidden = false;
editor.hidden = true;
editorHint.hidden = true;
erro.hidden = true;
editarBtn.hidden = true;
cancelarBtn.hidden = true;
salvarBtn.hidden = true;
modal.hidden = false;
let ajuda = null;
let podeEditar = false;
try {
const me = await pidGetMe();
podeEditar = !!me.eh_perfil_inovacao;
ajuda = await pidApiRequest(`/ajuda-aplicacoes/${encodeURIComponent(appKey)}/`);
} catch (err) {
textoEl.textContent = `Não foi possível carregar o texto de ajuda. (${err.message || "erro desconhecido"})`;
return;
}
function renderVisualizacao() {
modal.dataset.editing = "false";
if (ajuda.texto) {
textoEl.innerHTML = ajuda.texto;
} else {
textoEl.textContent = "Ainda não há um texto cadastrado para esta ferramenta.";
}
textoEl.hidden = false;
editor.hidden = true;
editorHint.hidden = true;
editarBtn.hidden = !podeEditar;
cancelarBtn.hidden = true;
salvarBtn.hidden = true;
erro.hidden = true;
}
function entrarEdicao() {
modal.dataset.editing = "true";
editor.innerHTML = ajuda.texto || "";
textoEl.hidden = true;
editor.hidden = false;
editorHint.hidden = false;
editarBtn.hidden = true;
cancelarBtn.hidden = false;
salvarBtn.hidden = false;
erro.hidden = true;
editor.focus();
}
editarBtn.onclick = entrarEdicao;
cancelarBtn.onclick = async () => {
if (await pidConfirm("Sair sem salvar as alterações?", { perigoso: true })) renderVisualizacao();
};
salvarBtn.onclick = async () => {
salvarBtn.disabled = true;
try {
ajuda = await pidApiRequest(`/ajuda-aplicacoes/${encodeURIComponent(appKey)}/`, {
method: "PATCH",
body: { texto: pidAjudaEditorValue(editor) },
});
renderVisualizacao();
} catch (err) {
erro.textContent = err.message || "Não foi possível salvar o texto.";
erro.hidden = false;
} finally {
salvarBtn.disabled = false;
}
};
renderVisualizacao();
}
function pidCriarBotaoAjuda(botaoId, appKey, tituloApp) {
const botao = document.getElementById(botaoId);
if (!botao) return;
botao.addEventListener("click", () => pidAbrirAjudaAplicacaoModal(appKey, tituloApp));
}

View File

@ -497,7 +497,7 @@ document.addEventListener("DOMContentLoaded", async () => {
modalDeleteBtn.addEventListener("click", async () => { modalDeleteBtn.addEventListener("click", async () => {
if (!editingEventId) return; if (!editingEventId) return;
if (window.confirm("Excluir este compromisso?")) { if (await pidConfirm("Excluir este compromisso?", { perigoso: true })) {
try { try {
await pidDeleteEvent(editingEventId); await pidDeleteEvent(editingEventId);
closeModal(); closeModal();

View File

@ -0,0 +1,91 @@
// Modais de confirmação/aviso genéricos, no padrão visual do Portal —
// substituem window.confirm()/window.alert() nativos do navegador (pedido
// explícito do usuário: nenhum popup deve usar o diálogo nativo do browser,
// sempre um modal dentro do próprio Portal).
//
// `pidConfirm(mensagem, opcoes)` devolve uma Promise<boolean> (true se
// confirmado, false se cancelado ou fechado clicando fora) — chame com
// `await` no lugar de `window.confirm(...)`.
//
// `pidAlert(mensagem, opcoes)` devolve uma Promise<void>, resolvida quando o
// usuário fecha o aviso (um único botão, sem "Cancelar") — chame com
// `await` no lugar de `window.alert(...)`.
//
// `opcoes` (todas opcionais): { titulo, textoConfirmar, textoCancelar,
// perigoso } — `perigoso: true` troca o botão de ação pra
// `.btn-danger-outline` (mesmo estilo já usado em ações destrutivas como
// "Excluir selecionadas"), em vez do `.btn-solid` padrão. Em `pidAlert`,
// `textoConfirmar` é o texto do único botão (default "OK").
//
// Único modal, compartilhado pelos dois, criado sob demanda e empilhado por
// cima de qualquer modal já aberto (`.modal-overlay--top`, z-index maior) —
// não fecha o modal por trás, só pergunta/avisa e devolve a resposta.
function pidConfirmModal() {
let modal = document.getElementById("pid-confirm-modal");
if (modal) return modal;
modal = document.createElement("div");
modal.className = "modal-overlay modal-overlay--top";
modal.id = "pid-confirm-modal";
modal.hidden = true;
modal.innerHTML = `
<div class="modal-card">
<h2 class="modal-card__title confirm-modal__titulo"></h2>
<p class="modal-card__subtitle confirm-modal__mensagem"></p>
<div class="modal-actions">
<button type="button" class="btn-outline confirm-modal__cancelar-btn"></button>
<button type="button" class="confirm-modal__confirmar-btn"></button>
</div>
</div>
`;
document.body.appendChild(modal);
return modal;
}
function pidConfirmOuAlerta(mensagem, opcoes, modoAlerta) {
const modal = pidConfirmModal();
const titulo = modal.querySelector(".confirm-modal__titulo");
const mensagemEl = modal.querySelector(".confirm-modal__mensagem");
const cancelarBtn = modal.querySelector(".confirm-modal__cancelar-btn");
const confirmarBtn = modal.querySelector(".confirm-modal__confirmar-btn");
titulo.textContent = opcoes.titulo || (modoAlerta ? "Aviso" : "Confirmar ação");
mensagemEl.textContent = mensagem;
cancelarBtn.hidden = modoAlerta;
cancelarBtn.textContent = opcoes.textoCancelar || "Cancelar";
confirmarBtn.textContent = opcoes.textoConfirmar || (modoAlerta ? "OK" : "Confirmar");
confirmarBtn.className = `confirm-modal__confirmar-btn ${opcoes.perigoso ? "btn-danger-outline" : "btn-solid"}`;
modal.hidden = false;
return new Promise((resolve) => {
function finalizar(resultado) {
modal.hidden = true;
cancelarBtn.removeEventListener("click", onCancelar);
confirmarBtn.removeEventListener("click", onConfirmar);
modal.removeEventListener("click", onOverlayClick);
resolve(resultado);
}
function onCancelar() {
finalizar(false);
}
function onConfirmar() {
finalizar(true);
}
function onOverlayClick(event) {
if (event.target === modal) finalizar(false);
}
cancelarBtn.addEventListener("click", onCancelar);
confirmarBtn.addEventListener("click", onConfirmar);
modal.addEventListener("click", onOverlayClick);
});
}
function pidConfirm(mensagem, opcoes = {}) {
return pidConfirmOuAlerta(mensagem, opcoes, false);
}
function pidAlert(mensagem, opcoes = {}) {
return pidConfirmOuAlerta(mensagem, opcoes, true).then(() => undefined);
}

View File

@ -199,6 +199,8 @@ document.addEventListener("DOMContentLoaded", async () => {
if (contentEl) contentEl.hidden = !canUse; if (contentEl) contentEl.hidden = !canUse;
if (!canUse) return; if (!canUse) return;
pidCriarBotaoAjuda("ips-ajuda-btn", "importacao-plano-saude", "Importação de Plano de Saúde");
const formView = document.getElementById("ips-form-view"); const formView = document.getElementById("ips-form-view");
const reviewView = document.getElementById("ips-review-view"); const reviewView = document.getElementById("ips-review-view");
@ -292,9 +294,6 @@ document.addEventListener("DOMContentLoaded", async () => {
const regracadExcluirBtn = document.getElementById("ips-regracad-excluir-btn"); const regracadExcluirBtn = document.getElementById("ips-regracad-excluir-btn");
const regracadSalvarBtn = document.getElementById("ips-regracad-salvar-btn"); const regracadSalvarBtn = document.getElementById("ips-regracad-salvar-btn");
const regracadCriarBtn = document.getElementById("ips-regracad-criar-btn"); const regracadCriarBtn = document.getElementById("ips-regracad-criar-btn");
const regracadConfirmFecharModal = document.getElementById("ips-regracad-confirm-fechar-modal");
const regracadConfirmFecharCancelarBtn = document.getElementById("ips-regracad-confirm-fechar-cancelar-btn");
const regracadConfirmFecharOkBtn = document.getElementById("ips-regracad-confirm-fechar-ok-btn");
const formError = document.getElementById("ips-form-error"); const formError = document.getElementById("ips-form-error");
const formSubmitBtn = document.getElementById("ips-form-submit-btn"); const formSubmitBtn = document.getElementById("ips-form-submit-btn");
@ -1373,7 +1372,6 @@ document.addEventListener("DOMContentLoaded", async () => {
async function abrirCadastroRegras() { async function abrirCadastroRegras() {
regracadModal.hidden = false; regracadModal.hidden = false;
if (regracadConfirmFecharModal) regracadConfirmFecharModal.hidden = true;
regracadEmpresaAtual = null; regracadEmpresaAtual = null;
regracadOperadoraAtual = null; regracadOperadoraAtual = null;
regracadRegraEmEdicao = null; regracadRegraEmEdicao = null;
@ -1910,38 +1908,18 @@ document.addEventListener("DOMContentLoaded", async () => {
if (regracadBtn) regracadBtn.addEventListener("click", abrirCadastroRegras); if (regracadBtn) regracadBtn.addEventListener("click", abrirCadastroRegras);
if (regracadFecharBtn) regracadFecharBtn.addEventListener("click", fecharCadastroRegras); if (regracadFecharBtn) regracadFecharBtn.addEventListener("click", fecharCadastroRegras);
if (regracadModal) { if (regracadModal) {
regracadModal.addEventListener("click", (event) => { // Diferente dos outros modais do app: este formulário costuma ter edição
// Diferente dos outros modais do app: este formulário costuma ter // em andamento (custeio, observações) — clicar fora por engano não deve
// edição em andamento (custeio, observações) — clicar fora por // descartar isso sem perguntar (pidConfirm, não window.confirm — ver
// engano não deve descartar isso sem perguntar. Usa um modal próprio // static/js/confirm-modal.js).
// (mesmo padrão visual de .modal-overlay/.modal-card do resto do regracadModal.addEventListener("click", async (event) => {
// app) em vez de window.confirm() — o diálogo nativo do browser if (event.target !== regracadModal) return;
// aparece com a URL do servidor ("192.168.x.x:8000 diz..."), fora do if (await pidConfirm("Fechar o cadastro de regras sem salvar as alterações?", { perigoso: true })) {
// visual do Portal. fecharCadastroRegras();
if (event.target === regracadModal && regracadConfirmFecharModal) {
regracadConfirmFecharModal.hidden = false;
} }
}); });
} }
if (regracadConfirmFecharCancelarBtn) {
regracadConfirmFecharCancelarBtn.addEventListener("click", () => {
regracadConfirmFecharModal.hidden = true;
});
}
if (regracadConfirmFecharOkBtn) {
regracadConfirmFecharOkBtn.addEventListener("click", () => {
regracadConfirmFecharModal.hidden = true;
fecharCadastroRegras();
});
}
if (regracadConfirmFecharModal) {
// Clicar fora do próprio diálogo de confirmação cancela (não fecha o
// cadastro) — mesmo espírito conservador do resto do app.
regracadConfirmFecharModal.addEventListener("click", (event) => {
if (event.target === regracadConfirmFecharModal) regracadConfirmFecharModal.hidden = true;
});
}
if (regracadNovaBtn) regracadNovaBtn.addEventListener("click", regracadEntrarModoNovo); if (regracadNovaBtn) regracadNovaBtn.addEventListener("click", regracadEntrarModoNovo);
if (regracadNovaOperadoraBtn) regracadNovaOperadoraBtn.addEventListener("click", regracadEntrarModoNovo); if (regracadNovaOperadoraBtn) regracadNovaOperadoraBtn.addEventListener("click", regracadEntrarModoNovo);
if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.addEventListener("click", regracadCancelarNovo); if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.addEventListener("click", regracadCancelarNovo);
@ -2046,7 +2024,12 @@ document.addEventListener("DOMContentLoaded", async () => {
if (regracadExcluirBtn) { if (regracadExcluirBtn) {
regracadExcluirBtn.addEventListener("click", async () => { regracadExcluirBtn.addEventListener("click", async () => {
if (!regracadRegraEmEdicao) return; if (!regracadRegraEmEdicao) return;
if (!window.confirm(`Excluir a regra "${regracadRegraEmEdicao.nome}"? Essa ação não pode ser desfeita.`)) return; if (
!(await pidConfirm(`Excluir a regra "${regracadRegraEmEdicao.nome}"? Essa ação não pode ser desfeita.`, {
perigoso: true,
}))
)
return;
regracadExcluirBtn.disabled = true; regracadExcluirBtn.disabled = true;
try { try {
await pidExcluirRegraCusteioPlanoSaude(regracadRegraEmEdicao.id); await pidExcluirRegraCusteioPlanoSaude(regracadRegraEmEdicao.id);
@ -2187,7 +2170,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const removeBtn = event.target.closest("[data-remover-linha]"); const removeBtn = event.target.closest("[data-remover-linha]");
if (removeBtn) { if (removeBtn) {
const linhaId = Number(removeBtn.getAttribute("data-remover-linha")); const linhaId = Number(removeBtn.getAttribute("data-remover-linha"));
if (!window.confirm("Remover esta linha? Ela não vai aparecer no arquivo gerado.")) return; if (!(await pidConfirm("Remover esta linha? Ela não vai aparecer no arquivo gerado.", { perigoso: true })))
return;
removeBtn.disabled = true; removeBtn.disabled = true;
try { try {
await pidExcluirLinhaPlanoSaude(linhaId); await pidExcluirLinhaPlanoSaude(linhaId);
@ -2212,7 +2196,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const reverterBtn = event.target.closest("[data-reverter-alteracao]"); const reverterBtn = event.target.closest("[data-reverter-alteracao]");
if (reverterBtn) { if (reverterBtn) {
const alteracaoId = Number(reverterBtn.getAttribute("data-reverter-alteracao")); const alteracaoId = Number(reverterBtn.getAttribute("data-reverter-alteracao"));
if (!window.confirm("Reverter esta alteração?")) return; if (!(await pidConfirm("Reverter esta alteração?"))) return;
reverterBtn.disabled = true; reverterBtn.disabled = true;
try { try {
await pidReverterAlteracaoPlanoSaude(alteracaoId); await pidReverterAlteracaoPlanoSaude(alteracaoId);
@ -2252,13 +2236,14 @@ document.addEventListener("DOMContentLoaded", async () => {
const removeBtn = event.target.closest("[data-remover-importacao]"); const removeBtn = event.target.closest("[data-remover-importacao]");
if (removeBtn) { if (removeBtn) {
const id = Number(removeBtn.getAttribute("data-remover-importacao")); const id = Number(removeBtn.getAttribute("data-remover-importacao"));
if (!window.confirm("Excluir esta importação do histórico? Essa ação não pode ser desfeita.")) return; if (!(await pidConfirm("Excluir esta importação do histórico? Essa ação não pode ser desfeita.", { perigoso: true })))
return;
try { try {
await pidExcluirImportacaoPlanoSaude(id); await pidExcluirImportacaoPlanoSaude(id);
selectedIds.delete(id); selectedIds.delete(id);
await refreshList(); await refreshList();
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
} }
return; return;
} }
@ -2272,7 +2257,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const detalhe = await pidFetchImportacaoPlanoSaude(id); const detalhe = await pidFetchImportacaoPlanoSaude(id);
abrirRevisao(detalhe); abrirRevisao(detalhe);
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
} }
}); });
@ -2306,13 +2291,13 @@ document.addEventListener("DOMContentLoaded", async () => {
ids.length === 1 ids.length === 1
? "Excluir esta importação do histórico? Essa ação não pode ser desfeita." ? "Excluir esta importação do histórico? Essa ação não pode ser desfeita."
: `Excluir estas ${ids.length} importações do histórico? Essa ação não pode ser desfeita.`; : `Excluir estas ${ids.length} importações do histórico? Essa ação não pode ser desfeita.`;
if (!window.confirm(confirmMsg)) return; if (!(await pidConfirm(confirmMsg, { perigoso: true }))) return;
listBulkDeleteBtn.disabled = true; listBulkDeleteBtn.disabled = true;
try { try {
await Promise.all(ids.map((id) => pidExcluirImportacaoPlanoSaude(id))); await Promise.all(ids.map((id) => pidExcluirImportacaoPlanoSaude(id)));
selectedIds.clear(); selectedIds.clear();
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
} finally { } finally {
listBulkDeleteBtn.disabled = false; listBulkDeleteBtn.disabled = false;
await refreshList(); await refreshList();

View File

@ -439,7 +439,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
const excluirBtn = event.target.closest("[data-ind-excluir]"); const excluirBtn = event.target.closest("[data-ind-excluir]");
if (excluirBtn) { if (excluirBtn) {
if (!window.confirm("Excluir esta apuração? Essa ação não pode ser desfeita.")) return; if (!(await pidConfirm("Excluir esta apuração? Essa ação não pode ser desfeita.", { perigoso: true }))) return;
await pidExcluirApuracaoIndicador(Number(excluirBtn.getAttribute("data-ind-excluir"))); await pidExcluirApuracaoIndicador(Number(excluirBtn.getAttribute("data-ind-excluir")));
await carregarLista(); await carregarLista();
} }
@ -1887,7 +1887,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
const excluirBtn = event.target.closest("[data-ind-criterio-excluir]"); const excluirBtn = event.target.closest("[data-ind-criterio-excluir]");
if (excluirBtn) { if (excluirBtn) {
if (!window.confirm("Excluir este critério?")) return; if (!(await pidConfirm("Excluir este critério?", { perigoso: true }))) return;
await pidExcluirCriterioIndicador(Number(excluirBtn.getAttribute("data-ind-criterio-excluir"))); await pidExcluirCriterioIndicador(Number(excluirBtn.getAttribute("data-ind-criterio-excluir")));
await carregarConfig(); await carregarConfig();
} }
@ -1901,7 +1901,7 @@ document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("ind-percentuais-body").addEventListener("click", async (event) => { document.getElementById("ind-percentuais-body").addEventListener("click", async (event) => {
const excluirBtn = event.target.closest("[data-ind-percentual-excluir]"); const excluirBtn = event.target.closest("[data-ind-percentual-excluir]");
if (excluirBtn) { if (excluirBtn) {
if (!window.confirm("Excluir este registro do histórico?")) return; if (!(await pidConfirm("Excluir este registro do histórico?", { perigoso: true }))) return;
await pidExcluirPercentualIndicador(Number(excluirBtn.getAttribute("data-ind-percentual-excluir"))); await pidExcluirPercentualIndicador(Number(excluirBtn.getAttribute("data-ind-percentual-excluir")));
await carregarConfig(); await carregarConfig();
} }
@ -2126,7 +2126,12 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
const excluirBtn = event.target.closest("[data-ind-departamento-excluir]"); const excluirBtn = event.target.closest("[data-ind-departamento-excluir]");
if (excluirBtn) { if (excluirBtn) {
if (!window.confirm("Excluir este departamento? Os critérios e percentuais dele também serão excluídos.")) return; if (
!(await pidConfirm("Excluir este departamento? Os critérios e percentuais dele também serão excluídos.", {
perigoso: true,
}))
)
return;
await pidExcluirDepartamentoIndicador(Number(excluirBtn.getAttribute("data-ind-departamento-excluir"))); await pidExcluirDepartamentoIndicador(Number(excluirBtn.getAttribute("data-ind-departamento-excluir")));
await carregarConfig(); await carregarConfig();
} }

View File

@ -239,7 +239,7 @@ document.addEventListener("DOMContentLoaded", async () => {
links = sortFavoritesFirst(rawLinks); links = sortFavoritesFirst(rawLinks);
render(); render();
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
} }
return; return;
} }
@ -256,7 +256,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (removeBtn) { if (removeBtn) {
const id = Number(removeBtn.getAttribute("data-link-remove")); const id = Number(removeBtn.getAttribute("data-link-remove"));
const link = links.find((l) => l.id === id); const link = links.find((l) => l.id === id);
if (link && window.confirm(`Remover o link "${link.nome}"?`)) { if (link && (await pidConfirm(`Remover o link "${link.nome}"?`, { perigoso: true }))) {
await pidDeleteLink(id); await pidDeleteLink(id);
await refresh(); await refresh();
} }

View File

@ -568,7 +568,7 @@ document.addEventListener("DOMContentLoaded", async () => {
perm.apps[key] = !checked; perm.apps[key] = !checked;
perm.enabled = wasEnabled; perm.enabled = wasEnabled;
checkbox.checked = !checked; checkbox.checked = !checked;
window.alert(`Não foi possível salvar: ${e.message}`); await pidAlert(`Não foi possível salvar: ${e.message}`);
} finally { } finally {
checkbox.disabled = false; checkbox.disabled = false;
} }
@ -585,7 +585,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (deleteTarget) { if (deleteTarget) {
const codigo = Number(deleteTarget.getAttribute("data-pa-delete")); const codigo = Number(deleteTarget.getAttribute("data-pa-delete"));
const profile = profiles.find((p) => p.codigo === codigo); const profile = profiles.find((p) => p.codigo === codigo);
if (profile && window.confirm(`Excluir o perfil "${profile.nome}"?`)) { if (profile && (await pidConfirm(`Excluir o perfil "${profile.nome}"?`, { perigoso: true }))) {
await pidDeletePerfil(codigo); await pidDeletePerfil(codigo);
profiles = (await pidFetchPerfis()).map(pidPerfilFromApi); profiles = (await pidFetchPerfis()).map(pidPerfilFromApi);
renderList(); renderList();

View File

@ -507,7 +507,7 @@ document.addEventListener("DOMContentLoaded", async () => {
try { try {
viewingAusencia = await pidFetchRamalAusencia(ramal.usuario_ausencia_ativa_id); viewingAusencia = await pidFetchRamalAusencia(ramal.usuario_ausencia_ativa_id);
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
return; return;
} }
viewingRamalContext = ramal; viewingRamalContext = ramal;
@ -592,7 +592,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (deleteBtn) { if (deleteBtn) {
const id = Number(deleteBtn.getAttribute("data-tel-delete")); const id = Number(deleteBtn.getAttribute("data-tel-delete"));
const tel = telefonesExternos.find((t) => t.id === id); const tel = telefonesExternos.find((t) => t.id === id);
if (tel && window.confirm(`Remover o telefone de "${tel.nome}"?`)) { if (tel && (await pidConfirm(`Remover o telefone de "${tel.nome}"?`, { perigoso: true }))) {
await pidDeleteTelefoneExterno(id); await pidDeleteTelefoneExterno(id);
await refreshTelefonesExternos(); await refreshTelefonesExternos();
} }
@ -651,7 +651,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (deleteBtn) { if (deleteBtn) {
const id = Number(deleteBtn.getAttribute("data-fun-delete")); const id = Number(deleteBtn.getAttribute("data-fun-delete"));
const fun = funcoesTelefonia.find((f) => f.id === id); const fun = funcoesTelefonia.find((f) => f.id === id);
if (fun && window.confirm(`Remover a função "${fun.comando}"?`)) { if (fun && (await pidConfirm(`Remover a função "${fun.comando}"?`, { perigoso: true }))) {
await pidDeleteFuncaoTelefonia(id); await pidDeleteFuncaoTelefonia(id);
await refreshFuncoesTelefonia(); await refreshFuncoesTelefonia();
} }
@ -782,13 +782,13 @@ document.addEventListener("DOMContentLoaded", async () => {
if (ausViewDeleteBtn) { if (ausViewDeleteBtn) {
ausViewDeleteBtn.addEventListener("click", async () => { ausViewDeleteBtn.addEventListener("click", async () => {
if (!window.confirm("Deletar esta ausência?")) return; if (!(await pidConfirm("Deletar esta ausência?", { perigoso: true }))) return;
try { try {
await pidDeleteRamalAusencia(viewingAusencia.id); await pidDeleteRamalAusencia(viewingAusencia.id);
closeAusenciaViewModal(); closeAusenciaViewModal();
await refresh(); await refresh();
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
} }
}); });
} }
@ -814,7 +814,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (deleteBtn) { if (deleteBtn) {
const avulsoId = Number(deleteBtn.getAttribute("data-ram-delete")); const avulsoId = Number(deleteBtn.getAttribute("data-ram-delete"));
const ramal = ramais.find((r) => r.avulso_id === avulsoId); const ramal = ramais.find((r) => r.avulso_id === avulsoId);
if (ramal && window.confirm(`Remover o ramal de "${ramal.nome_exibicao}"?`)) { if (ramal && (await pidConfirm(`Remover o ramal de "${ramal.nome_exibicao}"?`, { perigoso: true }))) {
await pidDeleteRamal(avulsoId); await pidDeleteRamal(avulsoId);
await refresh(); await refresh();
} }

View File

@ -359,13 +359,13 @@ document.addEventListener("DOMContentLoaded", async () => {
const id = Number(deleteBtn.getAttribute("data-dep-delete")); const id = Number(deleteBtn.getAttribute("data-dep-delete"));
const dep = departamentosCache.find((d) => d.id === id); const dep = departamentosCache.find((d) => d.id === id);
if (!dep) return; if (!dep) return;
if (!window.confirm(`Excluir o departamento "${dep.nome}"?`)) return; if (!(await pidConfirm(`Excluir o departamento "${dep.nome}"?`, { perigoso: true }))) return;
try { try {
await pidDeleteDepartamento(id); await pidDeleteDepartamento(id);
const selected = selectedDepartamentoIds().filter((depId) => depId !== id); const selected = selectedDepartamentoIds().filter((depId) => depId !== id);
await fillDepartamentoChecklist(selected); await fillDepartamentoChecklist(selected);
} catch (e) { } catch (e) {
window.alert(e.message); await pidAlert(e.message);
} }
} }
}); });
@ -382,11 +382,11 @@ document.addEventListener("DOMContentLoaded", async () => {
if (deleteTarget) { if (deleteTarget) {
const id = Number(deleteTarget.getAttribute("data-ua-delete")); const id = Number(deleteTarget.getAttribute("data-ua-delete"));
if (id === me.id) { if (id === me.id) {
window.alert("Você não pode excluir o usuário com o qual está logado."); await pidAlert("Você não pode excluir o usuário com o qual está logado.");
return; return;
} }
const account = users.find((u) => u.id === id); const account = users.find((u) => u.id === id);
if (account && window.confirm(`Excluir o usuário "${account.nome}"?`)) { if (account && (await pidConfirm(`Excluir o usuário "${account.nome}"?`, { perigoso: true }))) {
await pidDeleteUsuario(id); await pidDeleteUsuario(id);
users = await pidFetchUsuarios(); users = await pidFetchUsuarios();
renderList(); renderList();
@ -398,13 +398,13 @@ document.addEventListener("DOMContentLoaded", async () => {
if (toggleTarget) { if (toggleTarget) {
const id = Number(toggleTarget.getAttribute("data-ua-toggle-active")); const id = Number(toggleTarget.getAttribute("data-ua-toggle-active"));
if (id === me.id) { if (id === me.id) {
window.alert("Você não pode desativar o usuário com o qual está logado."); await pidAlert("Você não pode desativar o usuário com o qual está logado.");
return; return;
} }
const account = users.find((u) => u.id === id); const account = users.find((u) => u.id === id);
if (!account) return; if (!account) return;
const acao = account.is_active ? "Desativar" : "Ativar"; const acao = account.is_active ? "Desativar" : "Ativar";
if (window.confirm(`${acao} o usuário "${account.nome}"?`)) { if (await pidConfirm(`${acao} o usuário "${account.nome}"?`)) {
await pidUpdateUsuario(id, { is_active: !account.is_active }); await pidUpdateUsuario(id, { is_active: !account.is_active });
users = await pidFetchUsuarios(); users = await pidFetchUsuarios();
renderList(); renderList();

View File

@ -290,7 +290,7 @@
<div id="ag-no-access" class="no-access" hidden> <div id="ag-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso a Acessos Gerais</h2> <h2>Sem acesso a Acessos Gerais</h2>
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Integração e Inovação a liberação em Perfis de Acesso.</p> <p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div> </div>
<div id="ag-content"> <div id="ag-content">
@ -530,6 +530,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -666,6 +666,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -290,7 +290,7 @@
<div id="cc-no-access" class="no-access" hidden> <div id="cc-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso à Simulação de Custo de Contratação</h2> <h2>Sem acesso à Simulação de Custo de Contratação</h2>
<p>Seu perfil de acesso não tem permissão para usar esta ferramenta. Solicite à Integração e Inovação a liberação em Perfis de Acesso.</p> <p>Seu perfil de acesso não tem permissão para usar esta ferramenta. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div> </div>
<div id="cc-content"> <div id="cc-content">
@ -652,6 +652,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -291,14 +291,20 @@
<div id="ips-no-access" class="no-access" hidden> <div id="ips-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso a Importação de Plano de Saúde</h2> <h2>Sem acesso a Importação de Plano de Saúde</h2>
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Integração e Inovação a liberação em Perfis de Acesso.</p> <p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div> </div>
<div id="ips-content"> <div id="ips-content">
<div id="ips-list-view"> <div id="ips-list-view">
<div class="ips-header"> <div class="ips-header">
<div> <div>
<div class="ips-title-row">
<h2>Importação de Plano de Saúde</h2> <h2>Importação de Plano de Saúde</h2>
<span class="info-tooltip">
<button type="button" class="info-tooltip__trigger" id="ips-ajuda-btn" aria-label="Mais informações sobre esta ferramenta">?</button>
<span class="info-tooltip__text" role="tooltip">Mais informações</span>
</span>
</div>
<p class="ips-subtitle">Gere o arquivo de lançamento de mensalidade/coparticipação no leiaute do sistema, a partir do relatório da operadora.</p> <p class="ips-subtitle">Gere o arquivo de lançamento de mensalidade/coparticipação no leiaute do sistema, a partir do relatório da operadora.</p>
</div> </div>
<div class="ips-header-actions"> <div class="ips-header-actions">
@ -493,7 +499,7 @@
<div class="modal-overlay" id="ips-regra-empresa-modal" hidden> <div class="modal-overlay" id="ips-regra-empresa-modal" hidden>
<div class="modal-card"> <div class="modal-card">
<h2 class="modal-card__title">Selecionar regra empresa</h2> <h2 class="modal-card__title">Selecionar regra empresa</h2>
<p class="ips-regra-actions__hint">Regras especiais cadastradas diretamente pela Integração e Inovação, por família — usadas aqui como o modo "mensalidade" de uma regra de custeio.</p> <p class="ips-regra-actions__hint">Regras especiais cadastradas diretamente pela Inovação, por família — usadas aqui como o modo "mensalidade" de uma regra de custeio.</p>
<div class="checklist-box ips-regras-lista" id="ips-regra-empresa-lista"></div> <div class="checklist-box ips-regras-lista" id="ips-regra-empresa-lista"></div>
<p class="modal-error" id="ips-regra-empresa-error"></p> <p class="modal-error" id="ips-regra-empresa-error"></p>
<div class="modal-actions"> <div class="modal-actions">
@ -663,7 +669,7 @@
Mensalidade usa regra especial da empresa Mensalidade usa regra especial da empresa
</label> </label>
<div class="ips-regra-empresa-box" id="ips-regra-empresa-box" hidden> <div class="ips-regra-empresa-box" id="ips-regra-empresa-box" hidden>
<p class="ips-regra-empresa-box__hint">Mensalidade custeada por uma regra especial cadastrada pela Integração e Inovação — substitui o custeio manual de titular/dependente acima.</p> <p class="ips-regra-empresa-box__hint">Mensalidade custeada por uma regra especial cadastrada pela Inovação — substitui o custeio manual de titular/dependente acima.</p>
<p class="ips-regra-empresa-box__atual" id="ips-regra-empresa-atual">Nenhuma regra selecionada.</p> <p class="ips-regra-empresa-box__atual" id="ips-regra-empresa-atual">Nenhuma regra selecionada.</p>
<button type="button" class="btn-outline" id="ips-regra-empresa-selecionar-btn">Selecionar regra</button> <button type="button" class="btn-outline" id="ips-regra-empresa-selecionar-btn">Selecionar regra</button>
</div> </div>
@ -687,16 +693,6 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="ips-regracad-confirm-fechar-modal" hidden>
<div class="modal-card">
<h2 class="modal-card__title">Fechar o cadastro de regras?</h2>
<p class="modal-card__subtitle">Alterações não salvas serão perdidas.</p>
<div class="modal-actions">
<button type="button" class="btn-outline" id="ips-regracad-confirm-fechar-cancelar-btn">Cancelar</button>
<button type="button" class="btn-danger-outline" id="ips-regracad-confirm-fechar-ok-btn">Fechar sem salvar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card" id="manage-account-modal-card"> <div class="modal-card" id="manage-account-modal-card">
@ -819,6 +815,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
@ -829,6 +826,7 @@
<script src="{% static 'js/events.js' %}"></script> <script src="{% static 'js/events.js' %}"></script>
<script src="{% static 'js/sidebar.js' %}"></script> <script src="{% static 'js/sidebar.js' %}"></script>
<script src="{% static 'js/notifications.js' %}"></script> <script src="{% static 'js/notifications.js' %}"></script>
<script src="{% static 'js/ajuda-aplicacao.js' %}"></script>
<script src="{% static 'js/importacao-plano-saude.js' %}"></script> <script src="{% static 'js/importacao-plano-saude.js' %}"></script>
</body> </body>
</html> </html>

View File

@ -80,7 +80,7 @@
</form> </form>
<p class="login-help"> <p class="login-help">
Esqueceu sua senha? Contate a <a href="#">Integração e Inovação</a> Esqueceu sua senha? Contate a <a href="#">Inovação</a>
</p> </p>
</div> </div>
</div> </div>
@ -118,6 +118,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/login-logo-blink.js' %}"></script> <script src="{% static 'js/login-logo-blink.js' %}"></script>
<script src="{% static 'js/login-intro.js' %}"></script> <script src="{% static 'js/login-intro.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>

View File

@ -291,7 +291,7 @@
<div id="ind-no-access" class="no-access" hidden> <div id="ind-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso ao Indicador de Desempenho</h2> <h2>Sem acesso ao Indicador de Desempenho</h2>
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Integração e Inovação a liberação em Perfis de Acesso.</p> <p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div> </div>
<div id="ind-content"> <div id="ind-content">
@ -906,6 +906,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -296,7 +296,7 @@
<div id="lf-no-access" class="no-access" hidden> <div id="lf-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso a Links &amp; Ferramentas</h2> <h2>Sem acesso a Links &amp; Ferramentas</h2>
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Integração e Inovação a liberação em Perfis de Acesso.</p> <p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div> </div>
<div id="lf-content"> <div id="lf-content">
@ -516,6 +516,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -581,6 +581,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -295,7 +295,7 @@
<div id="no-access-message" class="no-access" hidden> <div id="no-access-message" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Nenhum perfil de acesso vinculado</h2> <h2>Nenhum perfil de acesso vinculado</h2>
<p>Seu usuário ainda não está vinculado a um perfil de acesso. Solicite à Integração e Inovação a vinculação em Perfis de Acesso.</p> <p>Seu usuário ainda não está vinculado a um perfil de acesso. Solicite à Inovação a vinculação em Perfis de Acesso.</p>
</div> </div>
<div id="dashboard-content"> <div id="dashboard-content">
@ -509,6 +509,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -291,7 +291,7 @@
<div id="ramais-no-access" class="no-access" hidden> <div id="ramais-no-access" class="no-access" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<h2>Sem acesso a Ramais</h2> <h2>Sem acesso a Ramais</h2>
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Integração e Inovação a liberação em Perfis de Acesso.</p> <p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
</div> </div>
<div id="ramais-content"> <div id="ramais-content">
@ -731,6 +731,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>

View File

@ -632,6 +632,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/confirm-modal.js' %}"></script>
<script src="{% static 'js/sidebar-brand.js' %}"></script> <script src="{% static 'js/sidebar-brand.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script> <script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>