Inclusão do Mais informações e correção visual da aplicação.
This commit is contained in:
parent
1fe649ac86
commit
8d2650812d
40
CLAUDE.md
40
CLAUDE.md
@ -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. |
|
||||||
|
|||||||
BIN
media/planos_saude/operadora/coparticipacao_itamed_-_08.2026.pdf
Normal file
BIN
media/planos_saude/operadora/coparticipacao_itamed_-_08.2026.pdf
Normal file
Binary file not shown.
@ -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;
|
||||||
|
@ -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")
|
||||||
|
|||||||
@ -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})")
|
||||||
|
|
||||||
|
|||||||
29
portal_api/migrations/0046_ajudaaplicacao.py
Normal file
29
portal_api/migrations/0046_ajudaaplicacao.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
19
portal_api/migrations/0047_alter_ajudaaplicacao_texto.py
Normal file
19
portal_api/migrations/0047_alter_ajudaaplicacao_texto.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
219
static/js/ajuda-aplicacao.js
Normal file
219
static/js/ajuda-aplicacao.js
Normal 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));
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
91
static/js/confirm-modal.js
Normal file
91
static/js/confirm-modal.js
Normal 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);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
<h2>Importação de Plano de Saúde</h2>
|
<div class="ips-title-row">
|
||||||
|
<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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 & Ferramentas</h2>
|
<h2>Sem acesso a Links & 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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user