Revisão estrutural do portal

This commit is contained in:
Gabriel 2026-10-01 13:50:20 -03:00
parent d780284898
commit 30cb81ccc8
73 changed files with 4500 additions and 1000 deletions

View File

@ -24,3 +24,13 @@ Compromissos ganharam campo de horário (ordenados por horário dentro do dia) e
Pedido: distinguir compromissos pessoais/departamentais de **eventos corporativos** formais (reuniões, treinamentos) — com categoria, local, modalidade (presencial/remoto/híbrido) e descrição — e restringir quem pode criar compromissos visíveis para o departamento ou para todos, que até então qualquer usuário podia fazer livremente. Pedido: distinguir compromissos pessoais/departamentais de **eventos corporativos** formais (reuniões, treinamentos) — com categoria, local, modalidade (presencial/remoto/híbrido) e descrição — e restringir quem pode criar compromissos visíveis para o departamento ou para todos, que até então qualquer usuário podia fazer livremente.
O que foi construído: `CategoriaEvento` (nome único + cor hex, cadastro editável direto no modal de criar/editar compromisso, botão "+" ao lado do `<select>` de categoria); `CompromissoAgenda` ganhou `categoria` (FK), `eh_evento` (booleano — força `visibilidade="todos"` na validação), `local`, `modalidade` e `descricao`. Nova permissão `calendario-individual-criar-evento` (`catalogo.py`, único `tool` dentro de um subgrupo — não um par visualizar/editar, já que o módulo em si é liberado a todo perfil) passou a ser exigida por `CompromissoAgendaSerializer.validate()` para qualquer compromisso com `visibilidade` em `departamento`/`todos` — liberada só pra "Integração e Inovação" no `seed_portal.py` por ora, mesmo cuidado de sempre (`calendario-individual` está em `BASE_KEYS`). No calendário, eventos ganharam uma 5ª categoria de filtro/cor (`"evento"`, token `--coral`), distinta das 4 já existentes (`somente_eu`/`departamento`/`todos`/`equipe`). Migrações `0018_remove_compromissoagenda_compartilhado_com_perfil_and_more`, `0019_categoriaevento_compromissoagenda_descricao_and_more` e `0020_compromissoagenda_eh_evento`. Detalhe técnico completo em `docs/calendario-individual/calendario-individual.md`, seção "Eventos Corporativos". O que foi construído: `CategoriaEvento` (nome único + cor hex, cadastro editável direto no modal de criar/editar compromisso, botão "+" ao lado do `<select>` de categoria); `CompromissoAgenda` ganhou `categoria` (FK), `eh_evento` (booleano — força `visibilidade="todos"` na validação), `local`, `modalidade` e `descricao`. Nova permissão `calendario-individual-criar-evento` (`catalogo.py`, único `tool` dentro de um subgrupo — não um par visualizar/editar, já que o módulo em si é liberado a todo perfil) passou a ser exigida por `CompromissoAgendaSerializer.validate()` para qualquer compromisso com `visibilidade` em `departamento`/`todos` — liberada só pra "Integração e Inovação" no `seed_portal.py` por ora, mesmo cuidado de sempre (`calendario-individual` está em `BASE_KEYS`). No calendário, eventos ganharam uma 5ª categoria de filtro/cor (`"evento"`, token `--coral`), distinta das 4 já existentes (`somente_eu`/`departamento`/`todos`/`equipe`). Migrações `0018_remove_compromissoagenda_compartilhado_com_perfil_and_more`, `0019_categoriaevento_compromissoagenda_descricao_and_more` e `0020_compromissoagenda_eh_evento`. Detalhe técnico completo em `docs/calendario-individual/calendario-individual.md`, seção "Eventos Corporativos".
### Rodada 36 — Revisão de interface de 30/09/2026: Calendário Individual
Correções vindas da revisão de interface de 30/09/2026 (ver `plano.md`), só no frontend:
- **Feriados**: se `/api/feriados/` falhar, `carregarFeriados()` devolve um `Map` vazio (sem guardar a falha em cache) e a grade sai sem feriados, em vez de o calendário inteiro sumir.
- **Falha de carga**: categorias que não carregam deixam o select com "Nenhuma (categorias indisponíveis, recarregue a página)"; compromissos que não carregam mostram `pidAlert` e mantêm a grade.
- **Envio duplo**: salvar compromisso e categoria ficam travados ("Salvando…") até a resposta; excluir trava o próprio botão.
- **Sair sem salvar**: Cancelar e clique fora dos modais de compromisso e de categoria comparam com o retrato da abertura e pedem confirmação quando houve alteração.
- Título do mês com só a primeira letra maiúscula no JS ("Setembro de 2026"), sem `text-transform: capitalize`; descrição do popup de detalhes com `white-space: pre-wrap` e `overflow-wrap: anywhere` (`.ic-view-descricao`); o "+" de novo compromisso fica sempre visível em `@media (hover: none)`; `autocomplete="off"` em título, local e nome da categoria.

View File

@ -43,3 +43,16 @@ Migração `0004` gerada e aplicada no ambiente local (Python 3.13/Postgres disp
Pedido, com uma tela do Asana como referência: um cadastro de acessos/logins compartilhados da equipe (ex.: login geral de um site), organizado em seções e linhas, com popup de detalhes por linha. Virou a segunda aplicação real da seção "Links & Ferramentas" — o item do menu, que antes era um link direto, passou a ser um `nav-group` expansível com dois sub-itens ("Links & Ferramentas" e "Acessos Gerais"), cada um favoritável separadamente. Pedido, com uma tela do Asana como referência: um cadastro de acessos/logins compartilhados da equipe (ex.: login geral de um site), organizado em seções e linhas, com popup de detalhes por linha. Virou a segunda aplicação real da seção "Links & Ferramentas" — o item do menu, que antes era um link direto, passou a ser um `nav-group` expansível com dois sub-itens ("Links & Ferramentas" e "Acessos Gerais"), cada um favoritável separadamente.
O que foi construído: dois models novos (`AcessoGeralSecao`, `AcessoGeral`, sem relação com `LinkFerramenta`), mesmo padrão visualizar/editar já usado em Links & Ferramentas (chaves próprias `acessos-gerais-visualizar`/`acessos-gerais-editar`); ordenação escopada por seção (não global); restrição opcional de seção por perfil (`perfis_restritos` M2M pra `PerfilAcesso` — filtro de **dado**, independente da árvore de permissões); e um editor de "Observações" com texto rico + imagens embutidas (`contenteditable`, colar/arrastar imagem vira `data:` URI, sem upload separado). Sanitização no backend via `nh3` (não `bleach`, sem manutenção desde 2023) — allowlist estrita de tags/atributos, permitindo só `<img>` com esquema `data:` além de tags de texto básicas, contra XSS via HTML malicioso injetado no payload. Migrações `0016_acessogeralsecao_acessogeral` e `0017_acessogeralsecao_perfis_restritos_and_more`. Detalhe técnico completo em `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`, seção "Acessos Gerais". O que foi construído: dois models novos (`AcessoGeralSecao`, `AcessoGeral`, sem relação com `LinkFerramenta`), mesmo padrão visualizar/editar já usado em Links & Ferramentas (chaves próprias `acessos-gerais-visualizar`/`acessos-gerais-editar`); ordenação escopada por seção (não global); restrição opcional de seção por perfil (`perfis_restritos` M2M pra `PerfilAcesso` — filtro de **dado**, independente da árvore de permissões); e um editor de "Observações" com texto rico + imagens embutidas (`contenteditable`, colar/arrastar imagem vira `data:` URI, sem upload separado). Sanitização no backend via `nh3` (não `bleach`, sem manutenção desde 2023) — allowlist estrita de tags/atributos, permitindo só `<img>` com esquema `data:` além de tags de texto básicas, contra XSS via HTML malicioso injetado no payload. Migrações `0016_acessogeralsecao_acessogeral` e `0017_acessogeralsecao_perfis_restritos_and_more`. Detalhe técnico completo em `docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md`, seção "Acessos Gerais".
### Rodada 35 — Revisão de interface de 30/09/2026: Links & Ferramentas e Acessos Gerais
Correções vindas da revisão de interface de 30/09/2026 (ver `plano.md`), só no frontend:
- **Crítico, com backend**: criar ou renomear seção chamava `pidFetchPerfis()` (`/api/perfis/`, que exige `gerencia_permissoes`), então quem só tinha `acessos-gerais-editar` clicava e nada acontecia. Nova action `GET /api/acessos-gerais-secoes/perfis/` em `AcessoGeralSecaoViewSet` (só `codigo`/`nome`, exige `acessos-gerais-editar`) e `ensurePerfisCache()` passou a usá-la, com aviso se falhar. Mexe em `views.py`: exige reiniciar o `runserver`.
- **Falha de carga**: nas duas telas o erro mostra "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação." no lugar da mensagem de lista vazia.
- **Envio duplo**: salvar link (upload do ícone pode demorar), seção e acesso ficam travados ("Salvando…"/"Adicionando…") até a resposta; a estrela de favorito também trava durante a requisição.
- **Falha silenciosa**: remover link, excluir seção/acesso, mover e arrastar passaram a mostrar `pidAlert` com título. Mover/arrastar sempre recarregam a lista do servidor depois, então uma ordem que a tela mostrou antes da hora é desfeita se a gravação falhar.
- **Acessos Gerais, arrastar**: a linha muda de posição na hora (o array local é reordenado, antes só mudava depois do refresh).
- **Sair sem salvar**: Cancelar e clique fora dos modais de link, seção e acesso comparam com o retrato da abertura (no acesso, inclusive o HTML das observações com imagem) e pedem confirmação quando houve alteração.
- Ícone do cartão com `width`/`height="115"` e `loading="lazy"`; nome do cartão, título do popup e usuário/senha longos com `overflow-wrap: anywhere`; `overscroll-behavior: contain` nas observações; "2 MB" com espaço não separável; `spellcheck="false"` em URL e usuário.

View File

@ -56,6 +56,7 @@ Nenhuma tela recalcula união de departamentos/liderança aqui — é uma aplica
| `/api/links-ferramentas/`, `/api/links-ferramentas/{id}/` | GET/POST/PATCH/DELETE | lista **compartilhada** (não por usuário); leitura exige `apps["links-ferramentas-visualizar"]` e escrita exige `apps["links-ferramentas-editar"]` em `permissoes["links-ferramentas"]` (`PermissaoApp`, gate por método em `get_permissions()` — ver "Modelo de permissões" abaixo); POST é `multipart/form-data` (aceita upload de `icone`); `ordem` sempre é atribuída pelo servidor na criação (ignora o que vier no payload), reordenar é PATCH trocando o `ordem` de dois itens | | `/api/links-ferramentas/`, `/api/links-ferramentas/{id}/` | GET/POST/PATCH/DELETE | lista **compartilhada** (não por usuário); leitura exige `apps["links-ferramentas-visualizar"]` e escrita exige `apps["links-ferramentas-editar"]` em `permissoes["links-ferramentas"]` (`PermissaoApp`, gate por método em `get_permissions()` — ver "Modelo de permissões" abaixo); POST é `multipart/form-data` (aceita upload de `icone`); `ordem` sempre é atribuída pelo servidor na criação (ignora o que vier no payload), reordenar é PATCH trocando o `ordem` de dois itens |
| `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado | | `/api/links-ferramentas-favoritos/`, `/api/links-ferramentas-favoritos/{link_id}/` | GET/POST/DELETE | favorito **por usuário** de um cartão (chave natural é `link_id`, o id do `LinkFerramenta` — mesmo padrão de `app_id`/`notif_id`); exige só `apps["links-ferramentas-visualizar"]` (favoritar não precisa de editar); só afeta a ordem de exibição em Links & Ferramentas e o widget "Links Favoritos", nunca o `ordem` compartilhado |
| `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário | | `/api/acessos-gerais-secoes/`, `/api/acessos-gerais-secoes/{id}/` | GET/POST/PATCH/DELETE | seções do cadastro "Acessos Gerais" (aplicação dentro da seção Links & Ferramentas); leitura exige `apps["acessos-gerais-visualizar"]`, escrita exige `apps["acessos-gerais-editar"]`; excluir uma seção também exclui (`CASCADE`) os acessos dela; GET só lista seções sem `perfis_restritos` ou com interseção com os perfis do usuário |
| `/api/acessos-gerais-secoes/perfis/` | GET | lista enxuta (`codigo`/`nome`) dos perfis para o checklist "restrita a" do modal de seção; exige só `apps["acessos-gerais-editar"]` (`/api/perfis/` exige `gerencia_permissoes`, e quem só editava Acessos Gerais não conseguia abrir o modal; revisão de interface de 30/09/2026) |
| `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) | | `/api/acessos-gerais/`, `/api/acessos-gerais/{id}/` | GET/POST/PATCH/DELETE | linhas (acessos/logins) dentro de uma seção; mesma permissão de `acessos-gerais-secoes`; `ordem` é escopada por `secao` (servidor calcula `max(ordem)` só entre as linhas da mesma seção) |
> Estes endpoints moravam na tabela de API do `CLAUDE.md` da raiz e foram trazidos para cá: endpoint de aplicação mora na doc da aplicação. A raiz mantém só os transversais (auth, `/api/me/`, catálogo, perfis, usuários, favoritos, widgets, compromissos, notificações). > Estes endpoints moravam na tabela de API do `CLAUDE.md` da raiz e foram trazidos para cá: endpoint de aplicação mora na doc da aplicação. A raiz mantém só os transversais (auth, `/api/me/`, catálogo, perfis, usuários, favoritos, widgets, compromissos, notificações).

View File

@ -85,3 +85,14 @@ Uma primeira versão tentou resolver isso em `Usuario.save()` (model), mas foi a
### Rodada 81 (parte) — Correção do `seed_portal.py` resetando nomes de perfil ### Rodada 81 (parte) — Correção do `seed_portal.py` resetando nomes de perfil
Ver `plano.md` (rodada 81) para o pedido completo de "Mais informações" por aplicação — mecanismo genérico e transversal. A parte específica desta aplicação: o usuário tinha renomeado o perfil "Integração e Inovação" (código 8) pra "Inovação" e criado um perfil novo "Integração" (código 9) — mas `seed_portal.py` fazia `update_or_create(codigo=..., defaults={"nome": ...})`, que reescrevia `nome` de volta pro valor original a cada execução do seed. Corrigido pra `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação); `ativo`/`gerencia_permissoes`/`permissoes` continuam realinhados a cada execução, de propósito. Todas as 8 telas/textos do frontend que ainda diziam "Integração e Inovação" foram atualizadas pra "Inovação". Ver `plano.md` (rodada 81) para o pedido completo de "Mais informações" por aplicação — mecanismo genérico e transversal. A parte específica desta aplicação: o usuário tinha renomeado o perfil "Integração e Inovação" (código 8) pra "Inovação" e criado um perfil novo "Integração" (código 9) — mas `seed_portal.py` fazia `update_or_create(codigo=..., defaults={"nome": ...})`, que reescrevia `nome` de volta pro valor original a cada execução do seed. Corrigido pra `get_or_create(codigo=..., defaults={"nome": ...})` (nome só gravado na criação); `ativo`/`gerencia_permissoes`/`permissoes` continuam realinhados a cada execução, de propósito. Todas as 8 telas/textos do frontend que ainda diziam "Integração e Inovação" foram atualizadas pra "Inovação".
### Rodada 82 — Revisão de interface de 30/09/2026: Perfis de Acesso e Usuários
Correções vindas da revisão de interface de 30/09/2026 (ver `plano.md`), só no frontend:
- **Envio duplo**: "Salvar" de perfil e de usuário e "Adicionar" departamento ficam travados ("Salvando…"/"Adicionando…") até a resposta; dois cliques não criam mais dois registros. Erro de validação leva o cursor ao campo que falta (Nome do perfil; Login/Nome/Senha do usuário; novo departamento); erro do servidor no perfil volta o cursor ao Nome e, no usuário, rola até a mensagem.
- **Sair sem salvar**: "Voltar"/"Cancelar" das duas telas de edição comparam com um retrato tirado na abertura (nome + árvore de permissões no perfil; todos os campos, checklists e liderados no usuário) e só então pedem `pidConfirm("Sair sem salvar as alterações?")`. Os vínculos da aba "Usuários do Escritório" ficam fora da comparação porque já são gravados na hora.
- **Falha silenciosa**: carga inicial (catálogo/perfis, usuários), abertura da edição de usuário, atualizar/vincular/desvincular em lote, excluir perfil/usuário/departamento e ativar/desativar usuário passaram a tratar erro (`pidAlert` com título ou aviso "Não foi possível carregar..." na própria tabela, nunca a mensagem de lista vazia).
- **Perfil novo**: a aba "Usuários do Escritório" fica desabilitada com a dica "Salve o perfil para vincular usuários." até o primeiro salvamento (antes vincular mandava código `null`).
- **Desativar usuário**: a confirmação avisa que isso remove todos os perfis e o tira da liderança de quem o lidera (`_revogar_acesso_se_inativo`), com botão de perigo.
- Ordenação com `localeCompare(..., "pt-BR")`; `tabular-nums` (`.pa-table .pa-num`) nas colunas de código e ramal; `.pa-toolbar`/`.pa-tabs` com `flex-wrap`; hover em `.pa-tab`; a regra de 1 coluna abaixo de 900px de `.ua-permissions-row` ganhou a mesma especificidade da de 2 colunas (antes perdia); placeholders com "…", `spellcheck="false"` em login, e-mail e códigos, `autocomplete="off"` no Nome, `inputmode="numeric"` no ramal e `translate="no"` nos cabeçalhos Questor/Tareffa/Contabit.

View File

@ -101,3 +101,15 @@ O que mudou:
Como isso mudou as **chaves** armazenadas em `PerfilAcesso.permissoes["ramais"]["apps"]` (não só valores), foi necessário reexecutar `seed_portal` pra sincronizar os 8 perfis com o novo formato — sem isso, todo perfil ficaria sem nenhum acesso a Ramais (as chaves antigas `visualizar`/`editar` não existem mais no catálogo). Confirmado via shell depois do reseed: os 7 perfis "normais" saíram com as 5 chaves `*-visualizar=True` e as 3 `*-editar=False`; só "Integração e Inovação" saiu com as 3 `*-editar=True` também. `gabriel`/`bruno` não foram tocados (proteção da rodada anterior, `[[feedback_seed_nao_reseta_gabriel_bruno]]` segue valendo). `manage.py check` limpo. Como isso mudou as **chaves** armazenadas em `PerfilAcesso.permissoes["ramais"]["apps"]` (não só valores), foi necessário reexecutar `seed_portal` pra sincronizar os 8 perfis com o novo formato — sem isso, todo perfil ficaria sem nenhum acesso a Ramais (as chaves antigas `visualizar`/`editar` não existem mais no catálogo). Confirmado via shell depois do reseed: os 7 perfis "normais" saíram com as 5 chaves `*-visualizar=True` e as 3 `*-editar=False`; só "Integração e Inovação" saiu com as 3 `*-editar=True` também. `gabriel`/`bruno` não foram tocados (proteção da rodada anterior, `[[feedback_seed_nao_reseta_gabriel_bruno]]` segue valendo). `manage.py check` limpo.
**Nota pra quem for customizar um perfil manualmente na tela de Perfis de Acesso**: qualquer ajuste fino que já tivesse sido feito nas chaves antigas (`ramais.visualizar`/`ramais.editar`) precisa ser refeito nas novas chaves — é uma troca de namespace, não uma migração automática de valor (JSONField não tem esse mecanismo). **Nota pra quem for customizar um perfil manualmente na tela de Perfis de Acesso**: qualquer ajuste fino que já tivesse sido feito nas chaves antigas (`ramais.visualizar`/`ramais.editar`) precisa ser refeito nas novas chaves — é uma troca de namespace, não uma migração automática de valor (JSONField não tem esse mecanismo).
### Rodada 33 — Revisão de interface de 30/09/2026: Ramais e consulta rápida
Correções vindas da revisão de interface de 30/09/2026 (ver `plano.md`), só no frontend:
- **Carga por permissão**: `ramais.js` só busca a lista de cada aba que o usuário pode visualizar (antes buscava as três e gerava 403 nas abas sem permissão). Falha de carga mostra "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação." na própria tabela.
- **Lista vazia x filtro**: Ramais e Telefones Externos diferenciam "Nenhum ... cadastrado ainda." de "Nenhum resultado para o filtro.".
- **Envio duplo**: os cinco "Salvar"/"Adicionar" (ramal, telefone externo, função, criar e editar ausência) ficam travados até a resposta (helper `comTrava`). Excluir ramal/telefone/função/ausência trava o próprio botão e mostra `pidAlert` na falha.
- **Sair sem salvar**: Cancelar e clique fora dos cinco formulários (inclusive o modo de edição do popup de ausência e o "Cancelar edição") comparam com o retrato da abertura e pedem confirmação quando houve alteração.
- **Ausência**: o select de usuário ganhou a opção vazia "Selecione…" (antes o primeiro nome vinha marcado) e a validação aponta e foca o campo que falta.
- **Consulta rápida** (`ramais-lookup.js`): erro de carga não vira mais "Nenhum ramal encontrado." nem fica em cache (a próxima abertura tenta de novo); "Carregando…"; ordenação com `localeCompare("pt-BR", { numeric: true })`; `overscroll-behavior: contain` na tabela e `tabular-nums` na coluna Ramal. Botão "Close" virou "Fechar" e o placeholder da busca usa "…" (em `links-ferramentas.html` e `calendario-individual.html`).
- Template: placeholders que repetiam o rótulo viraram exemplos ("Ex.: Recepção", "Ex.: 201"...), telefone com `type="tel"`, ramal com `inputmode="numeric"`, comando de função com `spellcheck="false"`; `tabular-nums` nas colunas de ramal/telefone.

View File

@ -533,6 +533,72 @@ ramal, perfil, acesso e observação, e conferir que aparecem **literalmente** e
todas as telas onde são exibidos, sem `&amp;` sobrando. A regra ficou registrada todas as telas onde são exibidos, sem `&amp;` sobrando. A regra ficou registrada
no `CLAUDE.md` ("Frontend consumindo a API"). no `CLAUDE.md` ("Frontend consumindo a API").
### 151. Revisão de interface: estrutura comum (login, Principal, sino, conta, widgets) (2026-10-01)
Continuação da revisão de interface (ver rodada 150 neste arquivo), na frente
da **estrutura comum**: o que todo shell carrega (`api.js`, `auth.js`,
`access.js`, `account.js`, `favorites.js`, `notifications.js`, `theme.js`,
`ajuda-aplicacao.js`, `tokens.css`, `base.css`, `layout.css`,
`components.css`) mais `index.html`/`login.css` e `portal.html`/`widgets.js`/
`widgets.css`. As telas de cada aplicação foram corrigidas em paralelo por
outras frentes, no `CHANGELOG.md` de cada uma.
- **Erro de carga deixa de ser silencioso**: `pidRequireAuth` (`auth.js`)
distingue 401 (`pidApiRequest` já redireciona e devolve `null`) de qualquer
outro erro (servidor fora, 500), que agora mostra `pidAlert` "Não foi possível
carregar seus dados..." em vez de deixar a página sem menu e sem explicação.
No sino, falha ao buscar compromissos ou notificações dispensadas não derruba
mais o resto (cada carga em `try/catch`), e vira aviso dentro da lista, nunca
"Nenhuma notificação". O mesmo em favoritos e widgets.
- **Mensagem genérica com próximo passo** (`api.js`, `PID_ERRO_GENERICO`):
"Erro ao processar a solicitação. Tente novamente; se persistir, contate a
Inovação."
- **Envio duplo e falha silenciosa**: estrela/remover/reordenar favoritos,
adicionar/remover/reordenar widgets, dispensar/Limpar tudo/Restaurar
notificação, Salvar liderados, Salvar senha e Salvar da Ajuda travam o
próprio controle durante a requisição e avisam a falha por `pidAlert`. Na
reordenação (favoritos e widgets), a tela sempre recarrega do servidor depois
do PATCH, então uma falha volta à ordem realmente gravada. Falha ao salvar o
tamanho de widget avisa (antes `.catch(() => {})`). Remover favorito e remover
widget pedem confirmação simples (não destrutiva).
- **Sair sem salvar só quando há alteração**: Gerenciar Usuário (compara os
liderados vinculados com `me.liderados`, inclusive ao ir pra "Alterar senha")
e Ajuda de aplicação (compara o HTML do editor com o da abertura). Troca de
senha com sucesso mostra "Senha alterada.".
- **Armazenamento bloqueado** (janela privada, política do navegador): leitura e
gravação de tema em `theme.js` e o `sessionStorage` da intro em `auth.js` em
`try/catch`.
- **Login**: campo de login com `spellcheck="false"`/`autocapitalize="none"`,
placeholders com "…", "Contate a Inovação" deixou de ser link `href="#"`
(texto com o mesmo destaque, `.login-help__destaque`), `.btn-primary` com
estado `:disabled`.
- **`portal.html`**: placeholders com "…", botão "Fechar" no modal "Adicionar
Widget", campos de senha com `name` e usuário oculto `autocomplete="username"`
(preenchido por `account.js` com `me.username`) pro gerenciador de senhas. Os
modais de senha dos outros shells não foram alterados nesta frente.
- **CSS transversal**: `color-scheme` dark/light em `tokens.css` (e `dark` fixo
na sidebar e no card do login, que são congelados escuros); `select`/`textarea`
herdam fonte/cor; `touch-action: manipulation` e sem realce de toque em
controles; `overscroll-behavior: contain` em modal e lista de notificações;
estado `:disabled` para `.btn-solid`/`.btn-outline`/`.btn-ghost`/
`.btn-danger-outline` (esmaecido, sem hover nem escala); título de notificação
quebra palavra longa; nome no `.persona-chip` corta com reticências; botões de
remover de favorito/widget visíveis em tela de toque (`@media (hover: none)`);
datas do widget de agenda com algarismos tabulares.
- **Datas**: `notifications.js` e `widgets.js` formatam com
`Intl.DateTimeFormat("pt-BR")`, montando a `Date` pelas partes da data pura
("AAAA-MM-DD"), sem passar por `new Date(string)` (que seria UTC e cairia no
dia anterior).
**Fora desta rodada, de propósito** (pedido do usuário, fica pra outra): tirar
`defer` de `theme.js`/`portal-reveal.js`, pular as intros do login, estado na
URL, trocar animações de `filter`, fontes do login/sidebar, favorito como `<a>`.
Nada de acessibilidade de teclado/leitor de tela nem `prefers-reduced-motion`.
**Validação**: só frontend, sem banco, API ou migration, não exige reiniciar o
`runserver`. Todos os JS alterados passaram em `node --check`. **Não testado no
navegador** (sem renderização neste ambiente).
## Limitações conhecidas / decisões assumidas ## Limitações conhecidas / decisões assumidas
- Calendário De Paula (empresa) não tem mais versão interna — depende - Calendário De Paula (empresa) não tem mais versão interna — depende

View File

@ -270,3 +270,40 @@ Conferido na conciliação 22 com o arquivo atualizado, em transação desfeita:
### Rodada 51 — Barra de progresso ao criar e ao atualizar (2026-09-30) ### Rodada 51 — Barra de progresso ao criar e ao atualizar (2026-09-30)
Pedido do usuário: mostrar o andamento enquanto a conciliação é criada ou o razão é atualizado, como no Relatório Contábil. Mesmo mecanismo de lá (`pidDcIniciarProgressoSimulado`): o processamento é uma requisição síncrona, sem percentual real, então a barra sobe rápido e desacelera perto de 90%, e só chega a 100% quando a resposta volta; em erro, some e a mensagem aparece. `pidConcIniciarProgresso()` em `conciliacao-fornecedores.js`, `.conc-progress*` no CSS, uma barra no formulário de Nova Conciliação e outra no modal "Atualizar razão". "Cancelar" fica desabilitado enquanto processa. Pedido do usuário: mostrar o andamento enquanto a conciliação é criada ou o razão é atualizado, como no Relatório Contábil. Mesmo mecanismo de lá (`pidDcIniciarProgressoSimulado`): o processamento é uma requisição síncrona, sem percentual real, então a barra sobe rápido e desacelera perto de 90%, e só chega a 100% quando a resposta volta; em erro, some e a mensagem aparece. `pidConcIniciarProgresso()` em `conciliacao-fornecedores.js`, `.conc-progress*` no CSS, uma barra no formulário de Nova Conciliação e outra no modal "Atualizar razão". "Cancelar" fica desabilitado enquanto processa.
### Rodada 52 — Conciliação não pode mais ser excluída (2026-09-30)
Pedido do usuário: com vários usuários acessando a ferramenta, excluir uma conciliação é arriscado, porque apaga o trabalho (vínculos, validações, histórico) sem rastro de quem excluiu. Removido o botão "Excluir" do histórico (`conciliacao-fornecedores.js`) e o método DELETE de `ConciliacaoFornecedorViewSet` (`http_method_names` sem `"delete"`, `perform_destroy()` removido), então a API responde 405 mesmo numa chamada direta. Exclusão agora só via código ou quando o usuário pedir explicitamente. Desfazer vínculo (`DELETE /api/conciliacoes-fornecedores-vinculos/{id}/`) continua, porque já fica registrado em `ConciliacaoFornecedorVinculoHistorico`.
### Rodada 53 — "Razão até" corrige data inexistente (2026-09-30)
Pedido do usuário: digitar 31/09/2026 era aceito pelo campo nativo `<input type="date">`, que só devolvia valor vazio, e a tela respondia "Informe até quando o razão foi exportado", sem apontar a data inválida. O ideal pedido: corrigir sozinho para 30/09/2026. O campo nativo não expõe o que foi digitado quando a data é inválida, então virou texto dd/mm/aaaa com máscara (`pidConcCampoData()` em `conciliacao-fornecedores.js`, nos dois campos: Nova Conciliação e Atualizar razão). Dia além do fim do mês vira o último dia do mês, com aviso abaixo do campo (inclui 29/02 em ano não bissexto). Mês inválido, dia 00 ou data incompleta mostram erro específico. O calendário continua disponível pelo botão do campo, que abre o seletor nativo de um `<input type="date">` escondido; esse campo escondido mantém o id antigo e o valor ISO, então o resto da tela e a API não mudaram.
### Rodada 54 — "Razão até" em branco na Nova Conciliação (2026-09-30)
Pedido do usuário: o campo vinha preenchido com o último dia do mês anterior (escolha da rodada 50 desta pasta, não pedida pelo usuário); agora a Nova Conciliação abre com ele em branco, e o contador informa a data. O modal Atualizar razão continua sugerindo a data (a mesma da Nova Conciliação, quando vem de lá; senão o último dia do mês anterior, nunca antes do fim atual).
### Rodada 55 — Ajuste de data sem aviso (2026-09-30)
Pedido do usuário: o texto "31/09/2026 não existe: ajustado para o último dia do mês..." é desnecessário. A correção para o último dia do mês continua, agora sem mensagem; o aviso abaixo do campo aparece só para data inválida (dia 00, mês fora de 1 a 12) ou incompleta.
### Rodada 56 — Melhorias da revisão de interface de 30/09/2026 (2026-09-30)
Pedido do usuário: implementar o relatório `docs/revisao-interface-2026-09-30-conciliacao-fornecedores.docx` (versão sem acessibilidade, decisão do usuário). Feito:
- **Observação não se perde**: o texto digitado e não salvo (aba da conciliação e aba dos títulos antigos) fica em `rascunhos`/`rascunhosVenc` por conta e sobrevive ao redesenho da conta (vincular, validar pelo checkbox), ao recolher/expandir e à troca de filtro. Sair da conciliação com rascunho pendente ("Voltar ao histórico", Voltar do navegador, fechar ou recarregar a página) pede confirmação. "Atualizar razão" reabre a mesma conciliação mantendo os rascunhos.
- **Sem envio duplo**: "Salvar observação" e "Marcar como validada/Remover validação" (nas duas abas) ficam desabilitados até a resposta.
- **Erro no formulário leva o cursor ao campo** (código da empresa, "Razão até"), na Nova Conciliação e no Atualizar razão.
- **Estado na URL**: aba, filtro de situação/alerta, "Mostrar validadas", ordenação e filtro/ordem da aba de títulos antigos vão para o hash (`#conciliacao-<id>?...`); recarregar volta ao mesmo ponto. Abrir uma conciliação pela lista agora empilha uma entrada (`pushState`): o Voltar do navegador volta à lista em vez de sair da tela.
- **Busca** (histórico e as duas abas do detalhe) espera 150 ms sem digitação antes de redesenhar.
- **Sem `title` nativo**: checkbox de validado, "marcar todos" e "Adicionar selecionados" usam o tooltip da tela.
- **Acabamento**: reticências "…" nos textos de espera, botões e placeholders; `tabular-nums` em `.conc-num`; `touch-action: manipulation` nas linhas clicáveis; `name` nos campos; `cursor: pointer` que sobrou no input de data escondido removido.
Não feito: `content-visibility: auto` nas linhas de fornecedor (sugerido no relatório). A propriedade não tem efeito em linhas de tabela (a contenção não se aplica a `<tr>`), e aplicá-la nas células desalinharia as colunas; o ganho real exigiria virtualizar a tabela, desproporcional para ~260 linhas.
### Rodada 57 — Relatório de análise: logo embutida e ano com 4 dígitos (2026-10-01)
Itens da revisão de interface de 30/09/2026 no relatório de análise para o contador (`templates/conciliacao-fornecedores-relatorio.html`):
- A logo vem embutida como data URI (`logo_data_uri`, montado por `_logo_relatorio_data_uri()` em `views.py`, o mesmo do Relatório Contábil), com fallback para o `{% static %}`: o HTML salvo e aberto fora do Portal não perde mais a imagem. Mexe em `views.py`: exige reiniciar o `runserver`.
- As datas de "validada em", "última ação" e "revisado em" passam de `d/m/y H:i` para `d/m/Y H:i`, como as demais datas do relatório.

View File

@ -21,7 +21,7 @@ O contador exporta do Questor o razão contábil das contas de fornecedores (que
## Leitura do razão (`parser.py`) ## Leitura do razão (`parser.py`)
Cada conta começa numa linha "Conta: <reduzido> - <classificação> <fornecedor>" (coluna Histórico), seguida de "Saldo anterior..." (só a coluna Saldo) e dos lançamentos. Não há linha de total nem cabeçalho de empresa/período: o **início** do período é a data do "Saldo anterior" + 1 dia (não o primeiro lançamento, que pode vir depois), e o **fim** é informado pelo contador no campo "Razão até" da criação e da atualização (o arquivo só traz até o último lançamento, e um mês sem movimento encurtaria o período; rodada 50 do CHANGELOG). `_conciliacao_aplica_periodo_fim()` (views.py) recusa data antes do último lançamento, antes do início, futura (serializer) ou, na atualização, antes do fim atual. Valores com lado "8.214,92D"/"16.429,84C". **Convenção interna: credor positivo** (valor devido ao fornecedor). `saldo_final_calculado` (saldo anterior + créditos − débitos) é comparado com o último saldo impresso (`saldo_final_arquivo`); diferença vira alerta alto (exportação incompleta). No arquivo de referência (`Projetos\Conciliação\Teste Exportação - Fornecedores - 2017.xlsx`, empresa 2017, 260 contas, 2.520 lançamentos, jan-ago/2026) as 260 contas batem. Cada conta começa numa linha "Conta: <reduzido> - <classificação> <fornecedor>" (coluna Histórico), seguida de "Saldo anterior..." (só a coluna Saldo) e dos lançamentos. Não há linha de total nem cabeçalho de empresa/período: o **início** do período é a data do "Saldo anterior" + 1 dia (não o primeiro lançamento, que pode vir depois), e o **fim** é informado pelo contador no campo "Razão até" da criação e da atualização (o arquivo só traz até o último lançamento, e um mês sem movimento encurtaria o período; rodada 50 do CHANGELOG). `_conciliacao_aplica_periodo_fim()` (views.py) recusa data antes do último lançamento, antes do início, futura (serializer) ou, na atualização, antes do fim atual. Na tela, o campo é texto dd/mm/aaaa (`pidConcCampoData()`, com botão que abre o calendário nativo de um `<input type="date">` escondido que guarda o ISO): dia além do fim do mês é ajustado para o último dia (31/09 → 30/09, em silêncio: o aviso abaixo do campo é só para data inválida ou incompleta, rodada 55), porque o campo nativo só devolvia valor vazio sem dizer o motivo (rodada 53 do CHANGELOG). Valores com lado "8.214,92D"/"16.429,84C". **Convenção interna: credor positivo** (valor devido ao fornecedor). `saldo_final_calculado` (saldo anterior + créditos − débitos) é comparado com o último saldo impresso (`saldo_final_arquivo`); diferença vira alerta alto (exportação incompleta). No arquivo de referência (`Projetos\Conciliação\Teste Exportação - Fornecedores - 2017.xlsx`, empresa 2017, 260 contas, 2.520 lançamentos, jan-ago/2026) as 260 contas batem.
Cada coluna é achada pelo início do nome no cabeçalho, só letras sem acento (ex. "SE" para "Seqüência", que pode vir com o acento corrompido; "DESC" para Descrição, para não confundir com Débito). Obrigatórias: Data, Seqüência, Histórico, Contrapartida, Descrição, Valor, Saldo; opcionais: Filial, Usuário, Origem; Débito, Crédito e Participante são ignoradas. Cada coluna é achada pelo início do nome no cabeçalho, só letras sem acento (ex. "SE" para "Seqüência", que pode vir com o acento corrompido; "DESC" para Descrição, para não confundir com Débito). Obrigatórias: Data, Seqüência, Histórico, Contrapartida, Descrição, Valor, Saldo; opcionais: Filial, Usuário, Origem; Débito, Crédito e Participante são ignoradas.
@ -97,7 +97,7 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`.
| Endpoint | Método | Uso | | Endpoint | Método | Uso |
|---|---|---| |---|---|---|
| `/api/conciliacoes-fornecedores/` | GET/POST | histórico (`?codigo_empresa=` filtra) / criação multipart (só `codigo_empresa` + `arquivo` + `periodo_fim` ("Razão até"); data-base e tolerâncias são definidas no servidor) | | `/api/conciliacoes-fornecedores/` | GET/POST | histórico (`?codigo_empresa=` filtra) / criação multipart (só `codigo_empresa` + `arquivo` + `periodo_fim` ("Razão até"); data-base e tolerâncias são definidas no servidor) |
| `/api/conciliacoes-fornecedores/{id}/` | GET/DELETE | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos; reaplica a validação automática e regrava o `resumo` se mudou) / exclusão (apaga o arquivo depois do commit) | | `/api/conciliacoes-fornecedores/{id}/` | GET | detalhe completo (contas + lançamentos + vínculos + análise, ~1 MB para 2.500 lançamentos; reaplica a validação automática e regrava o `resumo` se mudou). **Sem DELETE** (405), decisão explícita do usuário por rastreabilidade: com vários usuários na ferramenta, excluir apagaria o trabalho sem registro de quem excluiu. Exclusão só via código (shell/script) ou quando o usuário pedir explicitamente; não reintroduzir o botão nem o método sem confirmar |
| `/api/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=` | GET | nome no Questor (`null` se não achou ou Questor fora) | | `/api/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=` | GET | nome no Questor (`null` se não achou ou Questor fora) |
| `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas | | `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas |
| `/api/conciliacoes-fornecedores/{id}/atualizar/` | POST | multipart `arquivo` + `periodo_fim` ("Razão até", não antes do fim atual): razão novo que substitui o da conciliação (ver "Atualização do razão"); devolve `{conciliacao, atualizacao}` (contagens) | | `/api/conciliacoes-fornecedores/{id}/atualizar/` | POST | multipart `arquivo` + `periodo_fim` ("Razão até", não antes do fim atual): razão novo que substitui o da conciliação (ver "Atualização do razão"); devolve `{conciliacao, atualizacao}` (contagens) |
@ -113,7 +113,7 @@ Os três endpoints de ajuste devolvem `{conta, resumo}`: a conta reanalisada e o
## Tela (`conciliacao-fornecedores.html` / `.js` / `.css`) ## Tela (`conciliacao-fornecedores.html` / `.js` / `.css`)
Duas vistas na mesma página. **Histórico**: formulário de nova conciliação (o código da empresa busca o nome no Questor com debounce; o nome do arquivo com prefixo numérico sugere o código) + tabela do histórico. **Detalhe** (`#conciliacao-<id>` na URL), desenhado para ser leve (pedido do usuário, validado antes num protótipo): Duas vistas na mesma página. **Histórico**: formulário de nova conciliação (o código da empresa busca o nome no Questor com debounce; o nome do arquivo com prefixo numérico sugere o código) + tabela do histórico. **Detalhe** (`#conciliacao-<id>` na URL, seguido de aba, filtros e ordenação: `#conciliacao-22?aba=vencidos&status=divergente&ordem=-saldo_final`, lido por `pidConcLeHash()`/`aplicarParametros()` e regravado com `replaceState` a cada redesenho por `atualizarUrl()`; abrir pela lista empilha uma entrada com `pushState`, então o Voltar do navegador e o "Voltar ao histórico" voltam à lista pelo `popstate`; rodada 56 do CHANGELOG), desenhado para ser leve (pedido do usuário, validado antes num protótipo). **Observação digitada e não salva** (das duas abas) fica em `rascunhos`/`rascunhosVenc` (por conta), sobrevive ao redesenho, ao recolher e à troca de filtro, e sair da conciliação (Voltar, Voltar do navegador, fechar/recarregar a página) com rascunho pendente pergunta antes; "Salvar observação"/"Marcar como validada" ficam travados durante o envio. Buscas com espera de 150 ms (`pidConcAtrasado()`). Nenhum `title` nativo na tela: as dicas usam `[data-conc-tooltip]` (no "Adicionar selecionados" desabilitado, no invólucro `[data-adicionar-dica]`, porque botão desabilitado não recebe o mouse):
- cabeçalho da empresa baixo, código e nome na mesma linha; - cabeçalho da empresa baixo, código e nome na mesma linha;
- **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e abre a aba própria, com quantos fornecedores já foram validados nela), pagamentos sem título (divergência, clica e filtra) e uma **barra de progresso da validação** ("146 de 260"); - **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e abre a aba própria, com quantos fornecedores já foram validados nela), pagamentos sem título (divergência, clica e filtra) e uma **barra de progresso da validação** ("146 de 260");
- os apontamentos menores (diferença de juros/desconto, pagamento sem título de mesmo valor, conta transitória) como **etiquetas** clicáveis numa linha, só quando existem; - os apontamentos menores (diferença de juros/desconto, pagamento sem título de mesmo valor, conta transitória) como **etiquetas** clicáveis numa linha, só quando existem;

View File

@ -9,3 +9,17 @@
Pedido (2026-08-11): substituir a planilha manual de custo de contratação (`projects/planilha de custo/*.xlsx`) por um formulário no Portal que gera um PDF pronto pra enviar ao cliente — primeira aplicação de "Geradoc" a sair do estado de placeholder. Pedido (2026-08-11): substituir a planilha manual de custo de contratação (`projects/planilha de custo/*.xlsx`) por um formulário no Portal que gera um PDF pronto pra enviar ao cliente — primeira aplicação de "Geradoc" a sair do estado de placeholder.
Decisões confirmadas com o usuário: v1 cobre só Empregado CLT (as outras 4 modalidades do pedido original — Simples Nacional, Regime Normal, Pró-labore, Empregado Doméstico — ficam para quando houver planilha de referência equivalente); cálculo pontual, sem persistência (devolve o PDF direto, nada salva no banco); corrigido um gap real da planilha original (dedução por dependente não somava à base do IRRF quando usava desconto real de INSS); e as tabelas de INSS/IRRF, que nasceram hardcoded, viraram um cadastro editável (`ParametroFiscalCustoContratacao`, singleton) na mesma rodada em que a redução de IRRF da Lei 15.270/2025 (vigente desde jan/2026) foi implementada, já que ambas mudam por lei/todo ano. PDF gerado via `reportlab` (adicionado ao `requirements.txt`), com cabeçalho nas cores reais da marca (dourado/marrom amostrados de `logo.png`), não o roxo do tema do Portal. Migração `0023_parametrofiscalcustocontratacao`. Detalhe técnico completo no `CLAUDE.md` desta pasta. Decisões confirmadas com o usuário: v1 cobre só Empregado CLT (as outras 4 modalidades do pedido original — Simples Nacional, Regime Normal, Pró-labore, Empregado Doméstico — ficam para quando houver planilha de referência equivalente); cálculo pontual, sem persistência (devolve o PDF direto, nada salva no banco); corrigido um gap real da planilha original (dedução por dependente não somava à base do IRRF quando usava desconto real de INSS); e as tabelas de INSS/IRRF, que nasceram hardcoded, viraram um cadastro editável (`ParametroFiscalCustoContratacao`, singleton) na mesma rodada em que a redução de IRRF da Lei 15.270/2025 (vigente desde jan/2026) foi implementada, já que ambas mudam por lei/todo ano. PDF gerado via `reportlab` (adicionado ao `requirements.txt`), com cabeçalho nas cores reais da marca (dourado/marrom amostrados de `logo.png`), não o roxo do tema do Portal. Migração `0023_parametrofiscalcustocontratacao`. Detalhe técnico completo no `CLAUDE.md` desta pasta.
### Rodada 38 — Revisão de interface (30/09/2026): validação, tabelas fiscais e formulário no celular
Correções de interface apontadas pela revisão de interface de 30/09/2026, só no frontend (`custo-contratacao.js`/`.css`/`.html`); nada mudou no cálculo nem no backend.
- **Tabelas fiscais sem valor inválido gravado como 0**: `parseValorBrEstrito()` aceita só número no formato brasileiro ("1.234,56" ou "1234,56"); campo inválido fica marcado em vermelho (`.is-invalido`), o cursor vai ao primeiro deles e nada é salvo. Antes um valor inválido virava 0 sem aviso e era gravado para todos os usuários.
- **Tabelas fiscais com milhar e coeficiente com vírgula**: os inputs das faixas e dos parâmetros passam a ser preenchidos com separador de milhar ("7.350,00", `formataValorBrMilhar`), compatível com `parseValorBrEstrito` ao salvar. O coeficiente B da Lei 15.270/2025 aparece com vírgula ("0,133145"); o salvar continua aceitando vírgula ou ponto. `formataValorBr` (sem milhar) continua sendo a usada no payload da simulação.
- **Falha ao carregar as tabelas fiscais**: mensagem com próximo passo ("Recarregue a página; se persistir, contate a Inovação.") e "Salvar tabelas" desabilitado até a carga terminar com sucesso, para não gravar tabelas vazias.
- **Validação do formulário**: item de "Outros itens"/"Outros benefícios"/"Outros descontos" com valor e sem nome bloqueia o envio (antes era descartado do cálculo sem aviso) e leva o cursor ao nome; salário contratual, % de INSS Empregador e % de FGTS em branco também levam o cursor ao campo, em vez de só mostrar a mensagem no rodapé.
- **Gerar PDF**: a aba é aberta no clique, antes do `fetch` (senão o bloqueador de pop-up costuma barrar), recebe o PDF quando ele chega e o blob é revogado depois; os botões mostram "Gerando…"/"Salvando…" e ficam travados até terminar.
- **Limpar**: pede confirmação (`pidConfirm`, perigoso) só quando há algo preenchido no formulário ou alguma linha incluída nos itens.
- **Campos e layout**: campos monetários e percentuais com `inputmode="decimal"` (inclusive as faixas criadas pelo JS); placeholder do coeficiente B "Ex.: 0,133145…"; linhas de 2 e 3 colunas do formulário e do painel fiscal viram uma coluna em tela de celular (até 640px, com escopo só no conteúdo da Simulação, sem afetar os modais comuns do shell); `tabular-nums` nos inputs das faixas.
Ficaram sem `inputmode="decimal"` de propósito os campos que aceitam valor **ou** percentual ("Adicional Art. 62" e os três descontos, ex.: "40%"): em teclado virtual de celular esse modo pode não oferecer o "%".

View File

@ -506,3 +506,33 @@ Pedido do usuário, com print: numa análise que só tem apontamentos de variaç
- Validar uma variação na Análise Vertical agora redesenha também a aba Observações, para as contagens de pendentes acompanharem. - Validar uma variação na Análise Vertical agora redesenha também a aba Observações, para as contagens de pendentes acompanharem.
Só `.html`/`.css`/`.js`: não exige reiniciar o `runserver`. O JS não pôde ser executado neste ambiente: conferir na tela. Só `.html`/`.css`/`.js`: não exige reiniciar o `runserver`. O JS não pôde ser executado neste ambiente: conferir na tela.
### Rodada 155 — Correções da revisão de interface de 30/09/2026
Pedido do usuário: corrigir os problemas de interface apontados na revisão de interface de 30/09/2026 (versão sem acessibilidade para teclado e leitor de tela, decisão do usuário).
**Feito antes, no mesmo dia** (inclui backend):
- `dashboard-contabil.html`: botões "Gerar Dashboard" passam a "Gerar Relatório".
- Relatório do cliente: as observações mostram o código da conta (filtro novo `codigo_conta` em `templatetags/contabil_extras.py`, aplicado a `obs.alvo_chave`) em vez da chave interna `codigo|descricao`; o `colspan` da Análise Vertical usa o contexto novo `analise_vertical_colunas`; a logo vem embutida como data URI (`logo_data_uri`, montado por `_logo_relatorio_data_uri()` em `views.py`), com fallback para o `{% static %}`, para o HTML salvo e aberto fora do Portal não perder a imagem; aberto por `file:`, o `<html>` ganha `dcr-fora-do-portal`, que esconde os botões de exportar (dependem de login no Portal).
**Tela de revisão** (`dashboard-contabil.js`/`.html`/`.css`):
- **Falha não fica silenciosa**: abrir uma análise, abrir a aba Dashboard (busca dos indicadores), "Concluir Análise" e a carga inicial da lista têm tratamento de erro. A carga inicial com erro mostra a mensagem de falha no lugar da lista, não "Nenhuma análise realizada ainda". `abrirRevisao()` só troca `apuracaoAtual` depois das duas buscas darem certo. Recarregar a lista ou os indicadores depois que um modal já fechou vira aviso (`pidAlert`), não erro escrito num modal invisível.
- **Sem envio duplo**: "Marcar como tratado"/"Ignorar" (os três botões do modal travados juntos), "Concluir Análise", "Abrir", salvar/excluir indicador, Processar, Reprocessar, salvar o Resumo do Fechamento e salvar observação (nova e edição) ficam desabilitados com "Salvando…"/"Processando…"/"Excluindo…"/"Abrindo…" até a resposta.
- **Modal "Revisar"**: fechar pelo overlay ou Cancelar com a justificativa alterada pergunta "Sair sem salvar as alterações?"; durante o envio não fecha.
- **Modal de indicador**: o "Sair sem salvar" só aparece quando algo mudou (foto do formulário inteiro, com componentes e seleções, tirada ao abrir).
- **Modal "Reprocessar"**: não fecha pelo overlay durante o processamento, então o erro continua visível no modal.
- **Texto não salvo não se perde**: o campo "Escreva uma observação nova…" e a edição inline guardam cada tecla por painel (`dcObsRascunhos`, chave `nova:<escopo>-<id da linha>`/`edit:<id da observação>`) e são repostos no fim de cada render, então expandir/recolher grupo, trocar os chips Todas/Visíveis/Internas, usar o olho, encerrar, excluir ou validar uma conta (antes aceito como perda na documentação) não esvaziam mais o campo. Fechar um painel com texto (Fechar, o próprio ícone, abrir outro painel no mesmo escopo, ir para a variação) pergunta antes. Voltar, Concluir, Reprocessar a apuração aberta e abrir outra análise perguntam quando há observação em digitação ou Resumo do Fechamento alterado (comparado ao valor logo depois de carregado ou salvo, não ao retorno sanitizado pelo `nh3`), e fechar/recarregar a página dispara o aviso do navegador (`beforeunload`). Mesmo modelo da Conciliação de Fornecedores.
- **Tooltip da tela no lugar do `title` nativo**: todos os botões com `title` (HTML e JS) passaram a `data-dc-dica`, mostrado por um balão único com o visual de `.dc-hover-tooltip__texto` (`pidDcMostraDica()`, `.dc-dica-flutuante`), sem envolver o botão em outro elemento. O balão some ao clicar (a tabela costuma ser redesenhada) e ao rolar.
- **Toque**: em `@media (hover: none)`, tocar no badge de "mudou no reprocessamento" (com o valor anterior) ou no de fonte de PDF atípica abre o tooltip (`.is-aberto`, `pidDcAlternaTooltipToque()`); tocar de novo ou fora fecha.
- **Barra de progresso** anima `transform: scaleX()` com `transform-origin: left`, não `width`.
- **Descrição da DRE/Análise Vertical** quebra linha (`white-space: normal` com `min-width: 16rem`) em vez de ser cortada sem reticências pela borda da tabela.
- **Acabamento**: reticências "…" nos placeholders, textos de espera e no corte do tooltip de variação; `name` e `autocomplete="off"` nos três campos de texto do modal de indicador.
**Relatório do cliente** (`dashboard-contabil-relatorio.html`):
- O hover do card de indicador (subir 3px) nunca aparecia porque o `animation ... both` mantinha o `transform` do último quadro: o card usa `animation-fill-mode: backwards`, que guarda o estado inicial no atraso e solta o estilo ao terminar. A impressão não muda (o `@media print` já zera toda `animation`).
- Indicador deslizante das abas anima só `transform` (`translateX` + `scaleX` sobre largura base de 100px), não `width`.
- Verso do card abre também no toque (`.is-virado`, só em `@media (hover: none)`, para o clique do mouse não deixar o card preso no verso); na impressão sai sempre a frente.
**Não feito**: moeda negativa no relatório sai "R$ -1.234,50" e na tela "-R$ 1.234,50"; a diferença está no filtro `moeda` de `contabil_extras.py` (backend), fora do escopo desta rodada. Estado na URL, otimização de `dcDescendentes()` e as fontes do Google no relatório ficam para outra rodada.
Só `.html`/`.css`/`.js` nesta parte: não exige reiniciar o `runserver` (as mudanças de backend do mesmo dia, sim). O JS passou no `node --check`, mas não foi executado num navegador neste ambiente: conferir na tela.

View File

@ -125,3 +125,23 @@ Pedido, testando a rodada 45: o campo "Adicionar gerente" era só texto livre
Implementado 100% no frontend, sem endpoint novo: `carregarGerentesSugeridos()` busca a apuração mais recente (`GET /api/indicadores-apuracoes/`, a lista já vem ordenada por `-competencia`/`-criado_em` via `IndicadorApuracao.Meta.ordering` — não precisou de parâmetro novo), pega o detalhe dela (`GET /api/indicadores-apuracoes/{id}/`) e extrai os nomes distintos de `colaborador.gerente`, excluindo os que já estão em `departamentoGerentesConfig` (já mapeados pra algum departamento). O popup "Gerenciar Gerentes" ganhou uma seção "Sugestões" entre a lista atual e o campo de texto — cada sugestão é um botão (`.checklist-item` reaproveitado como `<button>`, mesmo padrão já usado em "Corrigir Responsável"/`.ind-corrigir-resultado`) que, ao ser clicado, já chama `POST /api/indicadores-departamentos-gerentes/` pra aquele departamento. Lista recarregada (`carregarGerentesSugeridos()` de novo) depois de qualquer adição/remoção de gerente, em qualquer departamento, pra manter as sugestões sempre refletindo quem ainda falta mapear. O campo de texto livre continua disponível, pra gerentes que não apareceram na última apuração (colaborador novo, ainda sem apuração processada). Implementado 100% no frontend, sem endpoint novo: `carregarGerentesSugeridos()` busca a apuração mais recente (`GET /api/indicadores-apuracoes/`, a lista já vem ordenada por `-competencia`/`-criado_em` via `IndicadorApuracao.Meta.ordering` — não precisou de parâmetro novo), pega o detalhe dela (`GET /api/indicadores-apuracoes/{id}/`) e extrai os nomes distintos de `colaborador.gerente`, excluindo os que já estão em `departamentoGerentesConfig` (já mapeados pra algum departamento). O popup "Gerenciar Gerentes" ganhou uma seção "Sugestões" entre a lista atual e o campo de texto — cada sugestão é um botão (`.checklist-item` reaproveitado como `<button>`, mesmo padrão já usado em "Corrigir Responsável"/`.ind-corrigir-resultado`) que, ao ser clicado, já chama `POST /api/indicadores-departamentos-gerentes/` pra aquele departamento. Lista recarregada (`carregarGerentesSugeridos()` de novo) depois de qualquer adição/remoção de gerente, em qualquer departamento, pra manter as sugestões sempre refletindo quem ainda falta mapear. O campo de texto livre continua disponível, pra gerentes que não apareceram na última apuração (colaborador novo, ainda sem apuração processada).
Validado com a apuração real do ambiente: as sugestões bateram exatamente com os 6 gerentes conhecidos (João Candido Rodrigues, Lhais Vergilio Delavy, Cristiano Silverio, Daniel Gustavo Manenti, Luciane Gonzaga, Elizangela de Paula Kuhn), já que nenhum deles está mapeado ainda no ambiente. Validado com a apuração real do ambiente: as sugestões bateram exatamente com os 6 gerentes conhecidos (João Candido Rodrigues, Lhais Vergilio Delavy, Cristiano Silverio, Daniel Gustavo Manenti, Luciane Gonzaga, Elizangela de Paula Kuhn), já que nenhum deles está mapeado ainda no ambiente.
### Rodada 47 — Melhorias da revisão de interface de 30/09/2026 (2026-10-01)
Pedido do usuário: aplicar os itens da revisão de interface de 30/09/2026 nesta tela (versão sem acessibilidade de teclado/leitor de tela, decisão do usuário). Só frontend (`indicador-desempenho.js`/`.html`/`.css`), sem migração nem mudança de API. Feito em duas etapas no mesmo pedido:
Primeira etapa (30/09):
- Percentuais e pesos com vírgula decimal (`Intl.NumberFormat("pt-BR")` em `pidIndFormatPercentual`/`pidIndFormatPeso`; antes `toFixed` mostrava "74.29%").
- `pidIndFormatData` trata data pura "AAAA-MM-DD" sem fuso (antes mostrava o dia anterior no horário de Brasília); `pidIndHojeIso()` preenche o "Vigente desde" com a data local (antes `toISOString()` dava o dia seguinte depois das 21h).
- O último `alert` nativo virou `pidAlert`; campos de honorário/percentual pré-preenchidos com vírgula.
Segunda etapa (01/10):
- **Card aberto não fecha mais**: todo ajuste da revisão (resposta de critério, meta, honorário, responsável, ajuste e lote) redesenhava a tela e fechava o card que o RH conferia. Agora os cards abertos ficam em `cardsExpandidos` (por id do colaborador) e `recarregarRevisao()` restaura também a rolagem da página. As respostas marcadas para o lote continuam marcadas depois do redesenho.
- **Sem envio duplo**: Processar, Salvar (honorário, responsável, percentual, critério, percentual por tipo, departamento), Aplicar, Ajustar/Reverter em lote, Gerar ("Gerando…"), Adicionar gerente e as sugestões ficam travados com o texto de espera até a resposta (`pidIndComBotaoOcupado`); selects de meta/resposta e botões de recalcular também.
- **Falha nunca silenciosa**: abrir/excluir apuração, voltar, abrir Configurações, ativar/excluir critério, excluir percentual e excluir departamento tratam erro com `pidAlert`. Erro na carga do histórico (inclusive a carga inicial) mostra "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação." na tabela, nunca "Nenhuma apuração realizada ainda". Falha na busca de sugestões de gerente diz isso em vez de "Nenhum gerente novo encontrado na última apuração". Se a ação deu certo e só a atualização da tela falhou, a mensagem diz isso (ex.: recibos já baixados).
- **Validação com campo e formato**: cada mensagem diz qual campo falta ou está errado e leva o cursor a ele (Nova Apuração, Critério, Percentual por Tipo, ajuste de percentual, departamento, gerente). Números aceitam milhar (`pidIndParseDecimal`: "1.234,56", "1.500"); o antigo `replace(",", ".")` só trocava a primeira vírgula e rejeitava esse formato.
- **Trabalho perdido**: Voltar/Cancelar/clique fora na Nova Apuração e nos modais de ajuste de percentual, Critério, Percentual por Tipo e Departamento pedem "Sair sem salvar as alterações?" só quando algum campo mudou (retrato dos campos ao abrir). O popup de gerentes pede o mesmo se houver nome digitado e não adicionado. Remover gerente de um departamento pede confirmação (perigosa).
- **Estados vazios** nas tabelas de Critérios, Percentuais e Departamentos ("Nenhum ... cadastrado." ou "Nenhum resultado para o filtro."). O filtro de departamento de Critérios/Percentuais não volta mais para "Todos" depois de salvar ou excluir.
- **Acabamento**: reticências "…", placeholders com exemplo ("Ex.: 08/2026…", "Ex.: 1.500,00…", nome do gerente), `inputmode="decimal"` nos campos numéricos, `name` nos campos, `color-scheme` também no "Vigente desde" (seguindo o tema claro), comentário e `cursor: pointer` errados removidos do campo de competência, popover do "Honorário Ajustado" centralizado por margem (a animação com `translateY` apagava o `translateX(-50%)` e o fazia pular meia largura), hover e `touch-action: manipulation` no cabeçalho do card, `overscroll-behavior: contain` no popup de Empresas sem Honorário e `tabular-nums` no total do card, nos percentuais e na tabela Empresas.
Não feito (outra rodada, por decisão do pedido): estado da tela na URL e virtualização das listas.

View File

@ -19,8 +19,8 @@ Segunda ferramenta de Geradoc — substitui a apuração manual do indicador de
**Limitação conhecida, validada com dados reais**: como a resolução é por `gerente` (não por colaborador), dois subordinados diretos do mesmo gerente sempre caem no mesmo departamento — isso quebra o caso de uma gerente que supervisiona pessoas de **departamentos diferentes**. Ex. real: "Elizangela de Paula Kuhn" supervisiona diretamente os líderes de Fisco/Contábil (João Candido Rodrigues, Lhais Vergilio Delavy, Paloma Ramão, Elizangela dos Santos — departamento "Gerentes") **e** Luciane Gonzaga (que deveria cair em "Rocket", já que ela chefia esse outro departamento) — como todos compartilham o mesmo `gerente`, mapear "Elizangela de Paula Kuhn" → "Gerentes" também classifica Luciane Gonzaga como "Gerentes", não "Rocket". Não existe mais um mecanismo de exceção por colaborador individual (o antigo `IndicadorSetorApelido` cobria exatamente esse tipo de caso) — se isso for um problema real, precisa ser resolvido numa rodada futura (ex.: reintroduzindo uma exceção por nome de colaborador, por cima da relação gerente→departamento). **Limitação conhecida, validada com dados reais**: como a resolução é por `gerente` (não por colaborador), dois subordinados diretos do mesmo gerente sempre caem no mesmo departamento — isso quebra o caso de uma gerente que supervisiona pessoas de **departamentos diferentes**. Ex. real: "Elizangela de Paula Kuhn" supervisiona diretamente os líderes de Fisco/Contábil (João Candido Rodrigues, Lhais Vergilio Delavy, Paloma Ramão, Elizangela dos Santos — departamento "Gerentes") **e** Luciane Gonzaga (que deveria cair em "Rocket", já que ela chefia esse outro departamento) — como todos compartilham o mesmo `gerente`, mapear "Elizangela de Paula Kuhn" → "Gerentes" também classifica Luciane Gonzaga como "Gerentes", não "Rocket". Não existe mais um mecanismo de exceção por colaborador individual (o antigo `IndicadorSetorApelido` cobria exatamente esse tipo de caso) — se isso for um problema real, precisa ser resolvido numa rodada futura (ex.: reintroduzindo uma exceção por nome de colaborador, por cima da relação gerente→departamento).
- **Detalhamento da composição no card do colaborador** (`portal_api.indicadores.calculo.composicao_individual`, exposto como o campo `composicao_individual` de `IndicadorApuracaoColaboradorSerializer`): reconstrói, só pra exibição, o percentual bruto de Individual (antes da composição) e o peso médio de cada um dos 3 níveis (`_peso_medio_nivel`) — dados que `recalcula_colaborador` calcula mas não persiste, por não precisar deles depois de gravar `pct_individual`. No cabeçalho do card (`indicador-desempenho.js`), essa linha ("Individual: X% (peso Y%) · Grupo: X% (peso Y%) · Departamento: X% (peso Y%)") fica ao lado do nome/gerente, numa coluna própria do grid centralizada — não embaixo — e cada um dos 3 níveis fica verde/vermelho conforme bateu 100% ou não; o "Total Indicador" (renomeado de "Individual", que é `pct_individual`, com o lápis de ajuste manual sempre ao lado do valor numa linha que não quebra) fica neutro, sem cor, pra não repetir a mesma informação 4 vezes. `composicao_individual()` usa `colaborador.respostas.all()` (não `.select_related("criterio")`) de propósito, pra reaproveitar o `prefetch_related("colaboradores__respostas__criterio")` que `IndicadorApuracaoViewSet.get_queryset()` aplica só na action `retrieve` — evita 1 query extra por colaborador ao abrir a tela de revisão. - **Detalhamento da composição no card do colaborador** (`portal_api.indicadores.calculo.composicao_individual`, exposto como o campo `composicao_individual` de `IndicadorApuracaoColaboradorSerializer`): reconstrói, só pra exibição, o percentual bruto de Individual (antes da composição) e o peso médio de cada um dos 3 níveis (`_peso_medio_nivel`) — dados que `recalcula_colaborador` calcula mas não persiste, por não precisar deles depois de gravar `pct_individual`. No cabeçalho do card (`indicador-desempenho.js`), essa linha ("Individual: X% (peso Y%) · Grupo: X% (peso Y%) · Departamento: X% (peso Y%)") fica ao lado do nome/gerente, numa coluna própria do grid centralizada — não embaixo — e cada um dos 3 níveis fica verde/vermelho conforme bateu 100% ou não; o "Total Indicador" (renomeado de "Individual", que é `pct_individual`, com o lápis de ajuste manual sempre ao lado do valor numa linha que não quebra) fica neutro, sem cor, pra não repetir a mesma informação 4 vezes. `composicao_individual()` usa `colaborador.respostas.all()` (não `.select_related("criterio")`) de propósito, pra reaproveitar o `prefetch_related("colaboradores__respostas__criterio")` que `IndicadorApuracaoViewSet.get_queryset()` aplica só na action `retrieve` — evita 1 query extra por colaborador ao abrir a tela de revisão.
- **Tabela "Metas de Grupo e Departamento" só tem uma forma de responder Sim/Não por critério** — a coluna "Meta (%)" (ajusta `pct_grupo`/`pct_departamento` direto). Existia um segundo `<select>` Sim/Não ao lado do texto de cada critério (bulk, via `aplicar-em-lote`), removido por ser redundante com o da direita; a lista de critérios ali agora é só informativa (nome + peso). Responder um critério específico continua possível por colaborador, dentro da lista de colaboradores abaixo (`renderRespostasGrupoHtml`). - **Tabela "Metas de Grupo e Departamento" só tem uma forma de responder Sim/Não por critério** — a coluna "Meta (%)" (ajusta `pct_grupo`/`pct_departamento` direto). Existia um segundo `<select>` Sim/Não ao lado do texto de cada critério (bulk, via `aplicar-em-lote`), removido por ser redundante com o da direita; a lista de critérios ali agora é só informativa (nome + peso). Responder um critério específico continua possível por colaborador, dentro da lista de colaboradores abaixo (`renderRespostasGrupoHtml`).
- **"Corrigir Responsável"** (`#ind-corrigir-responsavel-btn`, popup próprio): busca uma empresa (por nome ou código, entre **todas** as empresas da apuração, não só as com problema de honorário — `empresasAgrupadasPorCodigo(() => true)`) e mostra, pra cada responsável dela (uma linha por `IndicadorApuracaoEmpresa`, ex.: "Valéria Bonete — Fiscal"), um `<select>` com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chama `POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` (`IndicadorApuracaoEmpresaViewSet.trocar_responsavel`, serializer `IndicadorApuracaoEmpresaTrocarResponsavelSerializer` com `{colaborador_id}`), que só troca a FK `colaborador` da linha (`codigo_empresa`/`tipo`/honorário continuam os mesmos) e recalcula **os dois** colaboradores envolvidos (o que perdeu a empresa e o que ganhou) — validado no backend contra: colaborador de outra apuração, colaborador igual ao atual, e colaborador que já é responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas pra ele). O `<select>` exclui o colaborador atual das opções e nasce com um placeholder desabilitado ("Selecionar novo responsável...") pra nunca reatribuir sem escolha explícita. - **"Corrigir Responsável"** (`#ind-corrigir-responsavel-btn`, popup próprio): busca uma empresa (por nome ou código, entre **todas** as empresas da apuração, não só as com problema de honorário — `empresasAgrupadasPorCodigo(() => true)`) e mostra, pra cada responsável dela (uma linha por `IndicadorApuracaoEmpresa`, ex.: "Valéria Bonete — Fiscal"), um `<select>` com os demais colaboradores da apuração pra reatribuir aquele papel. Reatribuir chama `POST /api/indicadores-apuracoes-empresas/{id}/trocar-responsavel/` (`IndicadorApuracaoEmpresaViewSet.trocar_responsavel`, serializer `IndicadorApuracaoEmpresaTrocarResponsavelSerializer` com `{colaborador_id}`), que só troca a FK `colaborador` da linha (`codigo_empresa`/`tipo`/honorário continuam os mesmos) e recalcula **os dois** colaboradores envolvidos (o que perdeu a empresa e o que ganhou) — validado no backend contra: colaborador de outra apuração, colaborador igual ao atual, e colaborador que já é responsável por essa mesma empresa/tipo (evitaria duas linhas duplicadas pra ele). O `<select>` exclui o colaborador atual das opções e nasce com um placeholder desabilitado ("Selecionar novo responsável…") pra nunca reatribuir sem escolha explícita.
- **Checklist de revisão do RH** (`IndicadorApuracaoColaborador.validado`, migração `0031`): um checkbox no início de cada card (`.ind-colaborador-card__validado`, primeira coluna do grid do cabeçalho), sem relação com nenhum cálculo — só ajuda o RH a controlar quem já conferiu numa apuração com muitos colaboradores. Marcado, a borda do card inteiro fica verde (`.ind-colaborador-card.is-validado`, mesma largura de sempre, só muda a cor, pra não deslocar layout). `POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` (`marcar_validado`, serializer `IndicadorApuracaoColaboradorValidadoSerializer` com `{validado}`) só grava o campo, sem chamar `recalcula_colaborador`. Diferente dos outros ajustes desta tela, o frontend **não** recarrega a apuração inteira depois de marcar/desmarcar (`renderRevisao()`) — atualiza só o card clicado localmente, pra não fechar outros cards já expandidos nem perder a posição de rolagem no meio de uma conferência longa; erro de rede reverte o checkbox e o estado em memória (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário). O `<label>` inteiro (não só o `<input>`) precisa ficar de fora do gate de clique que expande/recolhe o card no cabeçalho, senão um clique na área do label (fora do glifo do checkbox) expande/recolhe o card ao mesmo tempo que marca/desmarca o validado — resultado de como labels HTML disparam dois eventos de clique encadeados. - **Checklist de revisão do RH** (`IndicadorApuracaoColaborador.validado`, migração `0031`): um checkbox no início de cada card (`.ind-colaborador-card__validado`, primeira coluna do grid do cabeçalho), sem relação com nenhum cálculo — só ajuda o RH a controlar quem já conferiu numa apuração com muitos colaboradores. Marcado, a borda do card inteiro fica verde (`.ind-colaborador-card.is-validado`, mesma largura de sempre, só muda a cor, pra não deslocar layout). `POST /api/indicadores-apuracoes-colaboradores/{id}/marcar-validado/` (`marcar_validado`, serializer `IndicadorApuracaoColaboradorValidadoSerializer` com `{validado}`) só grava o campo, sem chamar `recalcula_colaborador`. Diferente dos outros ajustes desta tela, o frontend **não** recarrega a apuração inteira depois de marcar/desmarcar: atualiza só o card clicado localmente. Os outros ajustes recarregam por `recarregarRevisao()`, que desde a revisão de interface de 30/09/2026 (rodada 47 do CHANGELOG) guarda os cards abertos, a rolagem e as respostas marcadas para o lote e os restaura depois do redesenho, então nenhum ajuste fecha mais o card em conferência; erro de rede reverte o checkbox e o estado em memória (mesmo padrão de outros toggles imediatos do app, ex. inativar usuário). O `<label>` inteiro (não só o `<input>`) precisa ficar de fora do gate de clique que expande/recolhe o card no cabeçalho, senão um clique na área do label (fora do glifo do checkbox) expande/recolhe o card ao mesmo tempo que marca/desmarca o validado — resultado de como labels HTML disparam dois eventos de clique encadeados.
- **Forçar SIM num critério automático não vira 100% na média** — a média ponderada usa o percentual real medido (`_fracao_atingida` em `calculo.py`), mesmo que o RH marque SIM por cima; só critério manual (sem `percentual_calculado`) é binário SIM=100%/resto=0%. Pra dar crédito cheio apesar do percentual medido baixo, o RH ajusta o percentual agregado direto (nível 2 acima), não o critério. - **Forçar SIM num critério automático não vira 100% na média** — a média ponderada usa o percentual real medido (`_fracao_atingida` em `calculo.py`), mesmo que o RH marque SIM por cima; só critério manual (sem `percentual_calculado`) é binário SIM=100%/resto=0%. Pra dar crédito cheio apesar do percentual medido baixo, o RH ajusta o percentual agregado direto (nível 2 acima), não o critério.
- **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados em `MEDIA_ROOT` (upload não é transacional), devolvendo 400 genérico. - **`create()` é atômico**: `IndicadorApuracaoViewSet.create()` roda o pipeline inteiro (parse das 2 planilhas + persistência de colaboradores/empresas/respostas) dentro de `transaction.atomic()` — uma falha no meio (planilha fora do leiaute, overflow decimal) desfaz tudo no banco e apaga os 2 arquivos recém-gravados em `MEDIA_ROOT` (upload não é transacional), devolvendo 400 genérico.
- **`POST /api/indicadores-apuracoes/{id}/gerar/`** monta um **ZIP** com um PDF de recibo por colaborador (`indicadores/recibo.py`, `reportlab`) a partir do que já está salvo — não reprocessa as planilhas, reflete qualquer ajuste manual feito na revisão. Recibo é documento interno (só quem tem a permissão do RH acessa/baixa) — sem visão própria do colaborador no Portal nesta v1. Botão "Gerar Recibos" (`indicador-desempenho.js`) abre um modal antes de chamar o endpoint — mesmo componente de busca por nome + filtro por departamento + checklist (com "marcar todos os resultados da busca") do "Ajuste Indicador em Lote", só que já nasce com todo mundo marcado (reproduz o comportamento antigo de "gerar pra todos" sem precisar marcar um por um); desmarcar alguns permite gerar recibo avulso de um colaborador só, de alguns específicos, ou de um departamento inteiro. O endpoint recebe `colaborador_ids` (lista, opcional) e só marca a apuração como `concluida` quando o conjunto pedido bate com **todos** os colaboradores da apuração (sem `colaborador_ids`, ou uma seleção que cobre o total) — gerar um recibo avulso pra conferência não fecha a apuração inteira como se o mês estivesse todo revisado. - **`POST /api/indicadores-apuracoes/{id}/gerar/`** monta um **ZIP** com um PDF de recibo por colaborador (`indicadores/recibo.py`, `reportlab`) a partir do que já está salvo — não reprocessa as planilhas, reflete qualquer ajuste manual feito na revisão. Recibo é documento interno (só quem tem a permissão do RH acessa/baixa) — sem visão própria do colaborador no Portal nesta v1. Botão "Gerar Recibos" (`indicador-desempenho.js`) abre um modal antes de chamar o endpoint — mesmo componente de busca por nome + filtro por departamento + checklist (com "marcar todos os resultados da busca") do "Ajuste Indicador em Lote", só que já nasce com todo mundo marcado (reproduz o comportamento antigo de "gerar pra todos" sem precisar marcar um por um); desmarcar alguns permite gerar recibo avulso de um colaborador só, de alguns específicos, ou de um departamento inteiro. O endpoint recebe `colaborador_ids` (lista, opcional) e só marca a apuração como `concluida` quando o conjunto pedido bate com **todos** os colaboradores da apuração (sem `colaborador_ids`, ou uma seleção que cobre o total) — gerar um recibo avulso pra conferência não fecha a apuração inteira como se o mês estivesse todo revisado.

View File

@ -70,3 +70,23 @@ Efeito colateral documentado (não é regressão, é a semântica correta): `res
Usuário reportou o aviso "Não foi possível copiar o texto." ao clicar em "Copiar" no popover do Resumo de Gestão, em produção. Causa: `navigator.clipboard.writeText()` (única chamada à Clipboard API no Portal) só funciona em contexto seguro (HTTPS ou `localhost`) — a produção deste Portal roda em HTTP puro hoje, sem TLS, e não há previsão de migrar pra HTTPS no momento (decisão de infraestrutura fora do escopo desta aplicação). Usuário reportou o aviso "Não foi possível copiar o texto." ao clicar em "Copiar" no popover do Resumo de Gestão, em produção. Causa: `navigator.clipboard.writeText()` (única chamada à Clipboard API no Portal) só funciona em contexto seguro (HTTPS ou `localhost`) — a produção deste Portal roda em HTTP puro hoje, sem TLS, e não há previsão de migrar pra HTTPS no momento (decisão de infraestrutura fora do escopo desta aplicação).
Correção: `resumoCopiarBtn` agora chama `pidNcfCopiarTexto()`, que usa `navigator.clipboard.writeText()` quando `window.isSecureContext` é verdadeiro e, senão, cai num fallback com `document.execCommand("copy")` via um `<textarea>` temporário fora da tela (`pidNcfCopiarTextoFallback`). Sem migração, sem mudança de contrato de API — só o handler de clique em `nao-conformidades.js`. Detalhe em `portal_api/nao_conformidades/CLAUDE.md`, seção "Frontend". Correção: `resumoCopiarBtn` agora chama `pidNcfCopiarTexto()`, que usa `navigator.clipboard.writeText()` quando `window.isSecureContext` é verdadeiro e, senão, cai num fallback com `document.execCommand("copy")` via um `<textarea>` temporário fora da tela (`pidNcfCopiarTextoFallback`). Sem migração, sem mudança de contrato de API — só o handler de clique em `nao-conformidades.js`. Detalhe em `portal_api/nao_conformidades/CLAUDE.md`, seção "Frontend".
### Rodada 108 — Melhorias da revisão de interface de 30/09/2026 (2026-10-01)
Pedido do usuário: aplicar os itens da revisão de interface de 30/09/2026 nesta tela (versão sem acessibilidade de teclado/leitor de tela, decisão do usuário). Feito em duas etapas no mesmo pedido:
Primeira etapa (30/09):
- O Resumo copiável mostra "Finalizada em" com `data_finalizacao` (antes usava a data de vencimento).
- A busca da Gestão também procura em tipos de causa (`tipos_causa` nas ocorrências, `ocorrencia_tipos_causa` nas ações). Para isso o `NCAcaoSerializer` ganhou o campo somente leitura `ocorrencia_tipos_causa` (`source="ocorrencia.tipos_causa"`), sem migração.
- Rolar dentro de um popover (lista de tipos do filtro de coluna, Resumo longo) não o fecha mais; antes o fim da lista ficava inalcançável.
Segunda etapa (01/10), só frontend (`nao-conformidades.js`/`.html`/`.css`):
- **Resposta antiga não vence mais**: cada carga (Sem análise, Análise sem ação, Ações, Dashboard) tem um contador; só a mais recente escreve na tela. Antes, cliques rápidos nos chips de Prazo, Tratativa ou Período deixavam a resposta que chegasse por último vencer, mesmo sendo do filtro anterior.
- **Falha não trava a aba**: `gestaoCarregada`/`importarCarregado` só viram verdadeiros depois de carregar com sucesso (antes viravam antes da carga e, se ela falhasse, a aba nunca mais recarregava). Erro de carga aparece no lugar da mensagem de lista vazia ("Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação."), inclusive na carga inicial do Dashboard e no histórico de importações.
- **Sem envio duplo**: "Marcar como tratado"/"Reabrir" e "Excluir" importação ficam travados ("Marcando…", "Reabrindo…", "Excluindo…") até a resposta.
- **Linhas abertas continuam abertas** depois de marcar como tratado, ordenar ou buscar (ids guardados por tabela e reabertos no redesenho). Falha ao carregar o detalhe de uma linha diz o próximo passo ("Feche e abra a linha de novo; se persistir, recarregue a página.") e reabrir a linha tenta de novo.
- **Busca** espera ~200 ms sem digitação antes de redesenhar as 3 tabelas (antes redesenhava a cada tecla).
- **Importação**: se o upload deu certo e só a atualização da tela falhou, a mensagem é "Importação concluída, mas não foi possível atualizar a tela. Recarregue a página.", não uma falha de upload.
- **Acabamento**: dias médios formatados com `Intl.NumberFormat("pt-BR")`, reticências "…" nos textos de espera e na busca (com `spellcheck="false"` e `name`), `color-scheme` nos campos de data do filtro de Vencimento seguindo o tema, `overflow-wrap: anywhere` nos textos longos do detalhe e do histórico, `tabular-nums` em cards, KPIs e rankings, nome de arquivo longo com reticências, `overscroll-behavior: contain` no popover do Resumo e `touch-action: manipulation` em linhas/cards clicáveis e no botão de expandir.
Não feito (outra rodada, por decisão do pedido): estado da tela na URL e virtualização das listas.

View File

@ -411,3 +411,21 @@ Na importação da empresa **221 (Rossoni Piotto)**, competência 09/2026, a lei
Corrigido em `operadoras/bradesco/saude.py`: a extração deixou de usar as células reconstruídas (`document.tables`) e passou a ler as palavras OCR com posição que o Docling guarda por trás de cada tabela (`pages[i].predictions.tablestructure`), reagrupadas em linhas físicas pela posição vertical. Cada campo é reconhecido pelo formato (Certif., Mês/Ano, valor), uma linha física vira um lançamento, e linha sem Certif. é somada ao beneficiário anterior. A rubrica passa a mostrar o Mês/Ano e o código de movimentação (ex.: "Mensalidade 08/2026 (IR)"). Conferência nova e obrigatória contra a linha "(TS)TOTAIS DA SUBFATURA" do quadro "Resumo" da fatura (valor, Part. Seg. e número de lançamentos); se não bater, a leitura falha em vez de lançar valor errado. Devolução (código CR, "(TD)" diferente de zero) ainda não apareceu em arquivo real, então essa conferência vai falhar de propósito no primeiro mês em que aparecer, até o formato ser confirmado. Corrigido em `operadoras/bradesco/saude.py`: a extração deixou de usar as células reconstruídas (`document.tables`) e passou a ler as palavras OCR com posição que o Docling guarda por trás de cada tabela (`pages[i].predictions.tablestructure`), reagrupadas em linhas físicas pela posição vertical. Cada campo é reconhecido pelo formato (Certif., Mês/Ano, valor), uma linha física vira um lançamento, e linha sem Certif. é somada ao beneficiário anterior. A rubrica passa a mostrar o Mês/Ano e o código de movimentação (ex.: "Mensalidade 08/2026 (IR)"). Conferência nova e obrigatória contra a linha "(TS)TOTAIS DA SUBFATURA" do quadro "Resumo" da fatura (valor, Part. Seg. e número de lançamentos); se não bater, a leitura falha em vez de lançar valor errado. Devolução (código CR, "(TD)" diferente de zero) ainda não apareceu em arquivo real, então essa conferência vai falhar de propósito no primeiro mês em que aparecer, até o formato ser confirmado.
Validado contra os dois arquivos reais da empresa 221: 09/2026 com 5 beneficiários, 7 lançamentos, R$ 8.641,73; 08/2026 com 4 beneficiários, R$ 4.656,02 (mesmo resultado de antes). Os dois batem com o Resumo impresso. O total do boleto (R$ 8.847,40) inclui R$ 205,67 de IOF, que não é lançamento de beneficiário. Validado contra os dois arquivos reais da empresa 221: 09/2026 com 5 beneficiários, 7 lançamentos, R$ 8.641,73; 08/2026 com 4 beneficiários, R$ 4.656,02 (mesmo resultado de antes). Os dois batem com o Resumo impresso. O total do boleto (R$ 8.847,40) inclui R$ 205,67 de IOF, que não é lançamento de beneficiário.
### Rodada 137 — Revisão de interface (30/09/2026): edição na Revisão, erros de carga e saída sem salvar
Correções de interface apontadas pela revisão de interface de 30/09/2026, só no frontend (`importacao-plano-saude.js`/`.css` e os dois templates); nada mudou no backend nem nas regras cadastradas. Valem para as duas instâncias (clientes e De Paula), que compartilham o mesmo JS/CSS.
- **Valores com separador de milhar na exibição**: `formatarValorExibicao()` (via `Intl.NumberFormat("pt-BR")`) passa a ser usada nos totais do rodapé, no Valor da aba Auditoria e nos textos do resumo da regra ("limite de R$ 1.234,56"). `formatarNumeroBr` continua **sem** milhar de propósito: ele preenche os inputs do Cadastro de Regras e o FormData da importação, que o servidor lê como número simples. Por isso os valores repostos nos inputs do Cadastro de Regras continuam sem milhar.
- **Edição de célula sem perder posição nem digitação**: cada edição (e ordenar/redimensionar coluna, trocar de aba) refaz o HTML dos painéis. Antes a tabela voltava ao topo, a altura ajustada à mão se perdia e, se o usuário já tinha começado a digitar na célula seguinte, o texto sumia quando o refetch terminava. `renderPanels()` agora guarda e repõe rolagem, altura e estado de expansão de cada `.ips-table-scroll` (por aba) e devolve o foco, o texto digitado e a posição do cursor à célula que estava em edição. Texto reposto por código não dispara `change` no navegador, então um `focusout` salva esse texto se o usuário sair da célula sem digitar mais nada (sem envio duplo). Todas as células editáveis ganharam `autocomplete="off"`; as de valor ficaram **sem** `inputmode="decimal"` de propósito (ver o cuidado abaixo).
- **Erro de carga nunca vira lista vazia**: falha ao buscar o histórico mostra "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação." no lugar de "Nenhuma importação realizada ainda."; o mesmo vale para operadoras, regras empresa e regras de custeio ao abrir "Nova Importação" ou "Cadastro de Regras" (mensagem no formulário e dentro da lista do combobox, em vez de "Nenhum resultado encontrado.").
- **Sair sem salvar**: no Cadastro de Regras, "Fechar" descartava edição e observações sem avisar, e o clique fora perguntava sempre, mesmo sem nada alterado. Os dois caminhos agora comparam o formulário com um retrato tirado ao abrir a regra (ou ao entrar em "+ Nova regra") e só perguntam ("Sair sem salvar as alterações?") quando há alteração real. "Cancelar"/"Voltar" da Nova Importação passaram a perguntar o mesmo quando há arquivo anexado, competência digitada ou empresa escolhida.
- **Nome da empresa**: falha na própria consulta (rede/servidor) mostra "Não foi possível consultar o Questor agora." em vez de "Empresa não encontrada.". Limitação que continua: o backend (`/nome-empresa/`) devolve `nome_empresa: null` tanto para código inexistente quanto para Questor fora do ar, então esse segundo caso ainda aparece como "Empresa não encontrada.".
- **Validação leva ao campo**: erros do "Processar" (empresa/operadora, competência, planilha, arquivos da operadora) e de "Salvar alterações"/"Criar regra" (código da empresa, operadora, custeio incompleto ou combinado) rolam até o campo com problema e põem o cursor nele quando dá. `mensagemErroCusteio()` virou `erroCusteio()`, que devolve `{ mensagem, campo }`. "Salvar alterações"/"Criar regra" mostram "Salvando…" enquanto enviam.
- **Validação de arquivo fora de ordem**: trocar de operadora revalida os arquivos anexados; a resposta de uma validação antiga podia chegar depois da nova e sobrescrever o status. Cada validação ganhou um token, e só a resposta mais recente vale (arquivo removido também é ignorado).
- **Clique duplo**: "Nova Importação" fica desabilitado com "Carregando…" enquanto as buscas de abertura rodam, e abrir uma importação do histórico ignora novos cliques até a Revisão abrir (a linha fica esmaecida com cursor de espera).
- **Versão De Paula**: o template não tem os três elementos do resumo da regra na Revisão (divergência já documentada no `CLAUDE.md` desta pasta), mas o JS considerava que havia resumo sempre que a importação tinha tipos, e a caixa `.ips-regra-empresa-observacoes` aparecia vazia, só com a borda. O JS agora só conta o resumo quando o elemento existe na página; o markup da variante não foi alterado.
- **Textos e campos**: reticências "..." viraram "…" (placeholders, "Validando arquivo…", "Buscar…", "Processando…", "Buscando nome da empresa…"); código da empresa em "+ Nova regra" com `inputmode="numeric"` e `spellcheck="false"`; limites e percentuais com `inputmode="decimal"` (nas duas páginas).
- **CSS**: algarismos de largura fixa (`tabular-nums`) nas colunas de valor, nos totais e nos contadores do resumo; `overflow-wrap: anywhere` nas observações; `overscroll-behavior: contain` no modal de Cadastro de Regras, na área rolável das tabelas e na lista do combobox; `flex-wrap` nas opções de origem da planilha.
Por que as células de valor não têm `inputmode="decimal"`: no teclado virtual de celular (principalmente iOS), esse modo pode não oferecer "-" e "+", que as células aceitam para expressões ("15,30-15"). Os campos de limite/percentual do cadastro de regras, que não aceitam expressão, ficaram com ele.

View File

@ -2104,6 +2104,9 @@ class NCAcaoSerializer(serializers.ModelSerializer):
ocorrencia_clientes = serializers.CharField(source="ocorrencia.clientes_relacionados", read_only=True) ocorrencia_clientes = serializers.CharField(source="ocorrencia.clientes_relacionados", read_only=True)
ocorrencia_area = serializers.CharField(source="ocorrencia.area", read_only=True) ocorrencia_area = serializers.CharField(source="ocorrencia.area", read_only=True)
ocorrencia_tipo_ocorrencia = serializers.CharField(source="ocorrencia.tipo_ocorrencia", read_only=True) ocorrencia_tipo_ocorrencia = serializers.CharField(source="ocorrencia.tipo_ocorrencia", read_only=True)
# Para o drill-down de "Motivos de abertura" do Dashboard achar as ações
# pela causa da ocorrência (revisão de interface de 30/09/2026).
ocorrencia_tipos_causa = serializers.CharField(source="ocorrencia.tipos_causa", read_only=True)
status_prazo = serializers.SerializerMethodField() status_prazo = serializers.SerializerMethodField()
status_prazo_label = serializers.SerializerMethodField() status_prazo_label = serializers.SerializerMethodField()
dias_para_vencer = serializers.SerializerMethodField() dias_para_vencer = serializers.SerializerMethodField()
@ -2118,6 +2121,7 @@ class NCAcaoSerializer(serializers.ModelSerializer):
"ocorrencia_clientes", "ocorrencia_clientes",
"ocorrencia_area", "ocorrencia_area",
"ocorrencia_tipo_ocorrencia", "ocorrencia_tipo_ocorrencia",
"ocorrencia_tipos_causa",
"codigo", "codigo",
"data_emissao", "data_emissao",
"tipo_acao", "tipo_acao",

View File

@ -25,6 +25,14 @@ def _agrupa_milhar(texto_us: str) -> str:
return texto_us.replace(",", "_").replace(".", ",").replace("_", ".") return texto_us.replace(",", "_").replace(".", ",").replace("_", ".")
@register.filter
def codigo_conta(chave: str | None) -> str:
"""Código da conta a partir da chave natural `codigo|descricao`
(`dashboard_contabil.chaves.chave_conta`). A chave inteira é interna e
não pode aparecer no relatório do cliente (revisão de 30/09/2026)."""
return (chave or "").split("|", 1)[0]
@register.filter @register.filter
def moeda(valor: Decimal | None) -> str: def moeda(valor: Decimal | None) -> str:
if valor is None: if valor is None:

View File

@ -1,6 +1,8 @@
import base64
import calendar import calendar
import csv import csv
import dataclasses import dataclasses
import functools
import hashlib import hashlib
import io import io
import itertools import itertools
@ -14,6 +16,7 @@ from decimal import Decimal
from typing import Any, Callable, Iterable from typing import Any, Callable, Iterable
import holidays import holidays
from django.contrib.staticfiles import finders
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.db import transaction from django.db import transaction
@ -231,6 +234,19 @@ from .serializers import (
) )
@functools.lru_cache(maxsize=1)
def _logo_relatorio_data_uri() -> str:
"""Logo do escritório (cópia reduzida, ~40 KB) embutido como data URI nos
relatórios HTML gerados (Relatório Contábil e Conciliação). Com o caminho
`/static/...`, o logo sumia no HTML salvo ou enviado ao cliente, fora do
Portal (revisão de interface de 30/09/2026)."""
caminho = finders.find("img/logo-branco-relatorio.png")
if not caminho:
return ""
with open(caminho, "rb") as arquivo:
return "data:image/png;base64," + base64.b64encode(arquivo.read()).decode("ascii")
@ensure_csrf_cookie @ensure_csrf_cookie
@api_view(["GET"]) @api_view(["GET"])
@permission_classes([AllowAny]) @permission_classes([AllowAny])
@ -618,6 +634,17 @@ class AcessoGeralSecaoViewSet(viewsets.ModelViewSet):
maior_ordem = AcessoGeralSecao.objects.aggregate(Max("ordem"))["ordem__max"] maior_ordem = AcessoGeralSecao.objects.aggregate(Max("ordem"))["ordem__max"]
serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1) serializer.save(ordem=0 if maior_ordem is None else maior_ordem + 1)
@action(detail=False, methods=["get"], url_path="perfis")
def perfis(self, request: Request) -> Response:
"""Lista enxuta (codigo/nome) dos perfis, para o checklist "restrita a"
do modal de seção. `/api/perfis/` exige PodeGerenciarPermissoes, então
quem só edita Acessos Gerais não conseguia abrir o modal (revisão de
interface de 30/09/2026); aqui basta `acessos-gerais-editar`."""
if not request.user.permissao_app("links-ferramentas", "acessos-gerais-editar"):
raise PermissionDenied("Você não tem permissão para editar Acessos Gerais.")
perfis = PerfilAcesso.objects.order_by("nome").values("codigo", "nome")
return Response(list(perfis))
class AcessoGeralViewSet(viewsets.ModelViewSet): class AcessoGeralViewSet(viewsets.ModelViewSet):
"""Linhas dentro de uma seção de "Acessos Gerais" — `ordem` é escopada por """Linhas dentro de uma seção de "Acessos Gerais" — `ordem` é escopada por
@ -4531,6 +4558,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
chaves_dre, chaves_dre,
), ),
"analise_vertical_meses": apuracao.analise_vertical_meses, "analise_vertical_meses": apuracao.analise_vertical_meses,
# Descrição + (valor, variação) por mês + Observação: o colspan da
# linha de observação usava meses + 2 e ficava espremido.
"analise_vertical_colunas": 2 * len(apuracao.analise_vertical_meses or []) + 2,
"linhas_analise_vertical": _contabil_arvore_contexto( "linhas_analise_vertical": _contabil_arvore_contexto(
linhas_analise_vertical, linhas_analise_vertical,
"linha", "linha",
@ -4560,6 +4590,7 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
), ),
"achados_com_observacao": dados_resumo["achados_com_observacao"], "achados_com_observacao": dados_resumo["achados_com_observacao"],
"gerado_em": timezone.now(), "gerado_em": timezone.now(),
"logo_data_uri": _logo_relatorio_data_uri(),
} }
html = render_to_string("dashboard-contabil-relatorio.html", contexto) html = render_to_string("dashboard-contabil-relatorio.html", contexto)
return HttpResponse(html, content_type="text/html; charset=utf-8") return HttpResponse(html, content_type="text/html; charset=utf-8")
@ -6009,9 +6040,11 @@ def _conciliacao_monta_relatorio(conciliacao: ConciliacaoFornecedor) -> concilia
class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet): class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
"""Histórico (GET lista, filtrável por `?codigo_empresa=`), criação """Histórico (GET lista, filtrável por `?codigo_empresa=`), criação
(multipart: só `codigo_empresa` + `arquivo`; data-base e tolerâncias (multipart: só `codigo_empresa` + `arquivo`; data-base e tolerâncias
são internas), detalhe completo e exclusão.""" são internas) e detalhe completo. Sem exclusão pela API, decisão do
usuário por rastreabilidade: excluir uma conciliação só via código ou a
pedido explícito."""
http_method_names = ["get", "post", "delete", "head", "options"] http_method_names = ["get", "post", "head", "options"]
serializer_class = ConciliacaoFornecedorListSerializer serializer_class = ConciliacaoFornecedorListSerializer
def get_permissions(self) -> list[BasePermission]: def get_permissions(self) -> list[BasePermission]:
@ -6098,13 +6131,6 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
raise raise
return Response(ConciliacaoFornecedorListSerializer(conciliacao).data, status=status.HTTP_201_CREATED) return Response(ConciliacaoFornecedorListSerializer(conciliacao).data, status=status.HTTP_201_CREATED)
def perform_destroy(self, instance: ConciliacaoFornecedor) -> None:
arquivo = instance.arquivo
with transaction.atomic():
instance.delete()
if arquivo:
arquivo.delete(save=False)
@action(detail=False, methods=["get"], url_path="nome-empresa") @action(detail=False, methods=["get"], url_path="nome-empresa")
def nome_empresa(self, request: Request) -> Response: def nome_empresa(self, request: Request) -> Response:
"""Nome da empresa no Questor pelo código digitado (mesma consulta e """Nome da empresa no Questor pelo código digitado (mesma consulta e
@ -6187,6 +6213,7 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
"meses_vencido": conciliacao_alertas.MESES_VENCIDO, "meses_vencido": conciliacao_alertas.MESES_VENCIDO,
"janela_duplicidade": conciliacao_relatorio.JANELA_DIAS_DUPLICIDADE, "janela_duplicidade": conciliacao_relatorio.JANELA_DIAS_DUPLICIDADE,
"gerado_em": timezone.now(), "gerado_em": timezone.now(),
"logo_data_uri": _logo_relatorio_data_uri(),
} }
html = render_to_string("conciliacao-fornecedores-relatorio.html", contexto) html = render_to_string("conciliacao-fornecedores-relatorio.html", contexto)
return HttpResponse(html, content_type="text/html; charset=utf-8") return HttpResponse(html, content_type="text/html; charset=utf-8")

View File

@ -190,6 +190,12 @@
gap: var(--space-3); gap: var(--space-3);
} }
/* Nome comprido sem espaço quebra no título em vez de empurrar o X pra fora. */
.ag-view-header .modal-card__title {
min-width: 0;
overflow-wrap: anywhere;
}
.ag-view-field { .ag-view-field {
margin-top: var(--space-3); margin-top: var(--space-3);
} }
@ -213,6 +219,8 @@
white-space: pre-wrap; white-space: pre-wrap;
max-height: 320px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
/* Rolar até o fim das observações não passa a rolar o fundo do modal. */
overscroll-behavior: contain;
} }
.ag-view-observacoes img { .ag-view-observacoes img {
@ -228,6 +236,9 @@
} }
.ag-view-copyable span { .ag-view-copyable span {
/* Usuário/senha longos (token, chave) quebram dentro do popup. */
min-width: 0;
overflow-wrap: anywhere;
font-size: 0.88rem; font-size: 0.88rem;
color: var(--text-primary); color: var(--text-primary);
} }
@ -244,6 +255,7 @@
min-height: 90px; min-height: 90px;
max-height: 260px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);

View File

@ -33,7 +33,9 @@ p {
} }
button, button,
input { input,
select,
textarea {
font-family: inherit; font-family: inherit;
color: inherit; color: inherit;
} }
@ -44,6 +46,17 @@ button {
background: none; background: none;
} }
/* Toque: tira o atraso de duplo toque (zoom) nos controles e o realce cinza
padrão do navegador móvel; o próprio componente já dá o retorno visual. */
button,
a,
[role="button"],
input[type="checkbox"],
label {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;

View File

@ -6,10 +6,11 @@
margin-bottom: var(--space-5); margin-bottom: var(--space-5);
} }
/* Sem text-transform: capitalize (deixava "Setembro De 2026"); a
maiúscula inicial vem do JS. */
.calendar-header__title { .calendar-header__title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
text-transform: capitalize;
min-width: 180px; min-width: 180px;
} }
@ -206,6 +207,13 @@
opacity: 1; opacity: 1;
} }
/* Tela de toque não tem hover: o "+" fica sempre visível. */
@media (hover: none) {
.calendar-day__add {
opacity: 1;
}
}
.calendar-day__add:hover { .calendar-day__add:hover {
color: var(--accent); color: var(--accent);
background: var(--bg-surface-raised); background: var(--bg-surface-raised);
@ -292,3 +300,10 @@
font-size: 0.6rem; font-size: 0.6rem;
} }
} }
/* Descrição no popup de detalhes: mantém as quebras de linha digitadas e
quebra palavra/URL comprida em vez de estourar o modal. */
.ic-view-descricao {
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@ -145,6 +145,37 @@
color: #fff; color: #fff;
} }
/* Botão travado (envio em andamento, ação indisponível): esmaecido e sem o
realce de hover/clique, pra não parecer que ainda responde. Os :hover acima
(e o de .btn-ghost, mais abaixo) têm especificidade menor que estes. */
.btn-solid:disabled,
.btn-outline:disabled,
.btn-ghost:disabled,
.btn-danger-outline:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.btn-solid:disabled:hover {
background: var(--accent);
}
.btn-outline:disabled:hover {
border-color: var(--border-subtle);
color: var(--text-secondary);
}
.btn-ghost:disabled:hover {
background: none;
color: var(--accent);
}
.btn-danger-outline:disabled:hover {
background: none;
color: var(--danger);
}
/* Modal genérico (reutilizado por compromissos, widgets, etc.) */ /* Modal genérico (reutilizado por compromissos, widgets, etc.) */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@ -155,6 +186,8 @@
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
padding: var(--space-5); padding: var(--space-5);
/* Rolar até o fim do modal não pode passar a rolar a página atrás dele. */
overscroll-behavior: contain;
animation: pidFadeIn 120ms ease; animation: pidFadeIn 120ms ease;
} }
@ -179,6 +212,7 @@
usuário a dar zoom out pra alcançar os botões no rodapé. */ usuário a dar zoom out pra alcançar os botões no rodapé. */
max-height: calc(100vh - var(--space-5) * 2); max-height: calc(100vh - var(--space-5) * 2);
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@ -680,6 +714,17 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
/* Nome longo não empurra o topbar: corta com reticências (ver #persona-chip-label). */
max-width: 240px;
min-width: 0;
}
/* O span do nome não tem classe própria nos shells; o id é o mesmo em todos. */
.persona-chip #persona-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.persona-chip:hover { .persona-chip:hover {
@ -895,6 +940,7 @@
.notif-list { .notif-list {
max-height: 360px; max-height: 360px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
@ -944,6 +990,8 @@
color: var(--text-primary); color: var(--text-primary);
line-height: 1.35; line-height: 1.35;
text-transform: uppercase; text-transform: uppercase;
/* Título de compromisso sem espaço (link, código) quebra em vez de estourar o dropdown. */
overflow-wrap: anywhere;
} }
.notif-item__breadcrumb { .notif-item__breadcrumb {
@ -1102,6 +1150,18 @@
opacity: 1; opacity: 1;
} }
/* Tela de toque não tem hover: sem isto o botão de remover nunca aparecia. */
@media (hover: none) {
.app-card__remove {
opacity: 1;
}
}
.app-card__remove:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app-card__remove:hover { .app-card__remove:hover {
color: var(--danger); color: var(--danger);
background: var(--bg-surface-raised); background: var(--bg-surface-raised);

View File

@ -32,6 +32,7 @@
.conc-num { .conc-num {
text-align: right !important; text-align: right !important;
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums;
} }
/* .pa-table td:last-child (perfis-acesso.css) alinha a última coluna à /* .pa-table td:last-child (perfis-acesso.css) alinha a última coluna à
@ -84,12 +85,12 @@
min-height: 1.2em; min-height: 1.2em;
} }
/* Campo "Razão até": ícone/popup nativo do calendário seguindo o tema (mesmo /* Campo "Razão até": popup nativo do calendário (aberto pelo botão do
ajuste do input de competência do Indicador de Desempenho). */ campo) seguindo o tema, mesmo ajuste do input de competência do
Indicador de Desempenho. */
#conc-form-periodo-fim, #conc-form-periodo-fim,
#conc-atualizar-periodo-fim { #conc-atualizar-periodo-fim {
color-scheme: dark; color-scheme: dark;
cursor: pointer;
} }
:root[data-theme="light"] #conc-form-periodo-fim, :root[data-theme="light"] #conc-form-periodo-fim,
@ -130,6 +131,55 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* "Razão até": texto dd/mm/aaaa digitável (corrige dia inexistente, ver
`pidConcCampoData`) + botão que abre o calendário nativo do <input
type="date"> escondido embaixo dele, que guarda o valor ISO. */
.conc-data-field {
position: relative;
}
/* `!important`: `.modal-field input:not(...)` (components.css) tem
especificidade maior; mesmo recurso de `.ips-combo__input`. */
.conc-data-field input[type="text"] {
padding-right: 40px !important;
}
.conc-data-field__btn {
position: absolute;
top: 50%;
right: var(--space-2);
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
}
.conc-data-field__btn:hover {
color: var(--text-primary);
}
.conc-data-field__picker {
position: absolute;
right: 0;
bottom: 0;
width: 1px !important;
height: 1px;
padding: 0 !important;
border: none !important;
opacity: 0;
pointer-events: none;
}
.conc-data-field__aviso.is-erro {
color: var(--danger);
}
.conc-form__empresa.is-erro { .conc-form__empresa.is-erro {
color: var(--danger); color: var(--danger);
} }
@ -454,8 +504,10 @@ button.conc-kpi.is-active {
width: 32px; width: 32px;
} }
/* touch-action: sem o atraso de toque duplo (zoom) no clique da linha */
.conc-row { .conc-row {
cursor: pointer; cursor: pointer;
touch-action: manipulation;
} }
.conc-row.is-aberta td { .conc-row.is-aberta td {
@ -1336,6 +1388,7 @@ button.conc-kpi.is-active {
.conc-subtabela--pendentes tbody tr { .conc-subtabela--pendentes tbody tr {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
touch-action: manipulation;
} }
.conc-subtabela--pendentes tbody tr .conc-historico { .conc-subtabela--pendentes tbody tr .conc-historico {
@ -1353,6 +1406,16 @@ button.conc-kpi.is-active {
flex-shrink: 0; flex-shrink: 0;
} }
/* "Adicionar selecionados" desabilitado: o mouse passa para o invólucro,
que mostra a dica no lugar do title nativo. */
.conc-adicionar-dica {
display: inline-flex;
}
.conc-adicionar-dica [data-adicionar]:disabled {
pointer-events: none;
}
.conc-vinculo__acoes button:disabled { .conc-vinculo__acoes button:disabled {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;

View File

@ -58,6 +58,19 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Celular: as linhas de 2 e 3 colunas do formulário e do painel de tabelas
fiscais viram uma coluna só. Escopo só no conteúdo da Simulação, pra não
mexer nos modais comuns do shell (conta, senha) que também usam
.modal-field-row. */
@media (max-width: 640px) {
.cc-form .modal-field-row,
.cc-form .cc-field-row--3,
.cc-painel-fiscal .modal-field-row,
.cc-painel-fiscal .cc-field-row--3 {
grid-template-columns: minmax(0, 1fr);
}
}
.cc-painel-fiscal { .cc-painel-fiscal {
max-width: 760px; max-width: 760px;
margin: 0 auto var(--space-5); margin: 0 auto var(--space-5);
@ -131,6 +144,13 @@
background: var(--bg-canvas); background: var(--bg-canvas);
color: var(--text-primary); color: var(--text-primary);
font-size: 0.85rem; font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
/* Valor inválido nas tabelas fiscais: marcado em vez de virar 0 ao salvar.
`!important`: `.modal-field input` (components.css) é mais específico. */
#cc-painel-fiscal-body input.is-invalido {
border-color: var(--danger) !important;
} }
.cc-tabela-fiscal__remove { .cc-tabela-fiscal__remove {

View File

@ -285,12 +285,17 @@
overflow: hidden; overflow: hidden;
} }
/* Largura sempre 100%: o avanço é um `transform: scaleX()` definido em JS
(pidDcIniciarProgressoSimulado), crescendo a partir da esquerda. Anima só
`transform`, nunca `width`, pra não refazer o layout a cada passo. */
.dc-progress__fill { .dc-progress__fill {
height: 100%; height: 100%;
width: 0%; width: 100%;
border-radius: inherit; border-radius: inherit;
background: var(--accent); background: var(--accent);
transition: width 0.2s ease-out; transform-origin: left;
transform: scaleX(0);
transition: transform 0.2s ease-out;
} }
.dc-progress__label { .dc-progress__label {
@ -1191,7 +1196,31 @@
} }
.dc-hover-tooltip__gatilho:hover + .dc-hover-tooltip__texto, .dc-hover-tooltip__gatilho:hover + .dc-hover-tooltip__texto,
.dc-hover-tooltip__gatilho:focus-visible + .dc-hover-tooltip__texto { .dc-hover-tooltip__gatilho:focus-visible + .dc-hover-tooltip__texto,
.dc-hover-tooltip.is-aberto .dc-hover-tooltip__texto {
opacity: 1;
}
/* Em tela de toque não existe hover: os badges informativos (valor antes do
reprocessamento, fonte de PDF atípica) abrem e fecham no toque
(`.is-aberto`, ver pidDcAlternaTooltipToque em dashboard-contabil.js). */
@media (hover: none) {
.dc-hover-tooltip__gatilho {
cursor: pointer;
}
}
/* Dica dos botões da tela (`data-dc-dica`), no lugar do `title` nativo do
navegador: um único balão com o mesmo visual de `.dc-hover-tooltip__texto`,
criado uma vez em `document.body` e posicionado em JS
(pidDcMostraDica em dashboard-contabil.js). `pointer-events:none` herdado,
então nunca bloqueia o clique no botão por baixo. */
.dc-dica-flutuante {
/* Acima de `.modal-overlay--top` (150): também serve aos botões dos modais. */
z-index: 200;
}
.dc-dica-flutuante.is-visivel {
opacity: 1; opacity: 1;
} }
@ -1480,8 +1509,19 @@
font-weight: 700; font-weight: 700;
} }
/* Descrição da DRE/Análise Vertical: quebra em mais de uma linha em vez de
ficar numa linha só (`nowrap`) e ser cortada sem aviso pela borda de
`.pa-table-wrap` (`overflow:hidden`). `min-width` evita que a coluna seja
espremida pelas colunas de valor quando há muitos meses na Análise
Vertical; o botão de expandir não encolhe junto do texto. */
.dc-dre-row td:first-child { .dc-dre-row td:first-child {
white-space: nowrap; white-space: normal;
min-width: 16rem;
}
.dc-dre-row td:first-child .dc-conta-toggle,
.dc-dre-row td:first-child .dc-conta-toggle-spacer {
flex-shrink: 0;
} }
/* Selos de severidade/status do achado — pequenos o bastante pra ficar ao /* Selos de severidade/status do achado — pequenos o bastante pra ficar ao

View File

@ -259,6 +259,7 @@
titular/dependente por baixo, só a escolha em si. */ titular/dependente por baixo, só a escolha em si. */
.ips-planilha-origem-opcoes { .ips-planilha-origem-opcoes {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-5); gap: var(--space-5);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
@ -467,6 +468,7 @@
z-index: 20; z-index: 20;
max-height: 220px; max-height: 220px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@ -616,6 +618,7 @@
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
} }
/* Cada tipo de lançamento tem uma regra própria pra titular e outra pra /* Cada tipo de lançamento tem uma regra própria pra titular e outra pra
@ -686,6 +689,7 @@
.ips-resumo strong { .ips-resumo strong {
color: var(--text-primary); color: var(--text-primary);
font-variant-numeric: tabular-nums;
} }
/* Tabela editável da revisão */ /* Tabela editável da revisão */
@ -743,6 +747,7 @@
precisa de alça própria. */ precisa de alça própria. */
resize: vertical; resize: vertical;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
/* .pa-table-wrap (perfis-acesso.css) tem overflow:hidden, que corta colunas /* .pa-table-wrap (perfis-acesso.css) tem overflow:hidden, que corta colunas
em vez de rolar quando a tabela é mais larga que o container — sobrescreve em vez de rolar quando a tabela é mais larga que o container — sobrescreve
aqui porque esta tabela tem 10 colunas editáveis, bem mais larga que as aqui porque esta tabela tem 10 colunas editáveis, bem mais larga que as
@ -800,6 +805,14 @@
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
/* Colunas de valor (Valor Empresa/Valor, total do rodapé e Valor da
Auditoria — classe posta pelo JS): algarismos de largura fixa, pra que os
valores fiquem alinhados entre linhas. */
.ips-grid-cell--valor,
.ips-grid-cell--valor .ips-table-input {
font-variant-numeric: tabular-nums;
}
.ips-grid-row:last-child .ips-grid-cell { .ips-grid-row:last-child .ips-grid-cell {
border-bottom: none; border-bottom: none;
} }
@ -1061,6 +1074,7 @@ body.is-resizing-column * {
#ips-regracad-modal .modal-card { #ips-regracad-modal .modal-card {
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
} }
/* #ips-regra-empresa-modal é aberto por cima de #ips-regracad-modal (ao /* #ips-regra-empresa-modal é aberto por cima de #ips-regracad-modal (ao
@ -1202,6 +1216,7 @@ body.is-resizing-column * {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--text-muted); color: var(--text-muted);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
} }
.ips-vincular-resumo { .ips-vincular-resumo {
@ -1209,3 +1224,11 @@ body.is-resizing-column * {
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
/* Linha do histórico sendo aberta (clique já registrado, Revisão ainda
carregando) — ver listBody "click" no JS, que ignora novos cliques até
terminar. */
#ips-list-body tr.ips-list-row--carregando td {
opacity: 0.55;
cursor: progress;
}

View File

@ -23,19 +23,37 @@
margin-top: 2px; margin-top: 2px;
} }
/* Input de competência (<input type="month">) — o ícone/popup nativo do /* Campos nativos do browser (competência, texto com máscara MM/AAAA, e o
browser não segue o tema do site por padrão; color-scheme faz o Chrome/Edge "Vigente desde", <input type="date">) seguem o tema do site: color-scheme
desenharem os dois de acordo, em vez do ícone de calendário escuro-sobre-nada faz o Chrome/Edge desenharem o ícone/popup de calendário e a seleção de
ilegível no fundo escuro do site. */ acordo, em vez do ícone escuro ilegível no fundo escuro. Sem cursor:
#ind-form-competencia { pointer: a competência é um campo de digitação, não um seletor. */
#ind-form-competencia,
#ind-percentual-vigente {
color-scheme: dark; color-scheme: dark;
cursor: pointer;
} }
:root[data-theme="light"] #ind-form-competencia { :root[data-theme="light"] #ind-form-competencia,
:root[data-theme="light"] #ind-percentual-vigente {
color-scheme: light; color-scheme: light;
} }
/* Estado vazio/erro de carga numa tabela (histórico e Configurações) —
especificidade acima de .pa-table td:last-child (text-align:right), já
que a célula única com colspan é sempre "a última". */
.pa-table td.ind-tabela-vazia {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: var(--space-5);
}
.pa-table td.ind-tabela-vazia--erro,
.ind-checklist-erro {
color: var(--danger);
font-style: normal;
}
/* Botão "Selecionar arquivo" com nome do arquivo ao lado, no lugar do /* Botão "Selecionar arquivo" com nome do arquivo ao lado, no lugar do
<input type="file"> nativo (feio e sem padronização entre browsers) — mesmo <input type="file"> nativo (feio e sem padronização entre browsers) — mesmo
padrão de .ips-file-field em importacao-plano-saude.css. */ padrão de .ips-file-field em importacao-plano-saude.css. */
@ -348,6 +366,7 @@
cada lista separada; título e "Fechar" ficam sempre visíveis fora dele. */ cada lista separada; título e "Fechar" ficam sempre visíveis fora dele. */
.ind-esh-scroll { .ind-esh-scroll {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
min-height: 0; min-height: 0;
padding-right: var(--space-2); padding-right: var(--space-2);
} }
@ -475,6 +494,13 @@
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
background: var(--bg-surface); background: var(--bg-surface);
cursor: pointer; cursor: pointer;
/* Toque abre/fecha o card sem o atraso de "duplo toque pra zoom". */
touch-action: manipulation;
transition: background var(--transition-fast);
}
.ind-colaborador-card__header:hover {
background: var(--card-bg-hover);
} }
.ind-colaborador-card__validado { .ind-colaborador-card__validado {
@ -556,6 +582,7 @@
.ind-pct-value { .ind-pct-value {
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums;
} }
/* Cada percentual da composição (Individual/Grupo/Departamento) só bate 100% /* Cada percentual da composição (Individual/Grupo/Departamento) só bate 100%
@ -605,6 +632,13 @@
font-weight: 700; font-weight: 700;
font-size: 1rem; font-size: 1rem;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums;
}
/* Valores da tabela "Empresas" (código, honorários, individual/grupo/
departamento/total) alinhados por dígito; nas colunas de texto não muda nada. */
.ind-empresas-table td {
font-variant-numeric: tabular-nums;
} }
.ind-colaborador-card__body { .ind-colaborador-card__body {
@ -665,11 +699,14 @@
border-color: var(--accent); border-color: var(--accent);
} }
/* Centralizado por margin-left (metade da largura), não por transform:
pidFadeSlideUp anima transform (translateY) e, enquanto rodava, apagava o
translateX(-50%), fazendo o popover "pular" meia largura pro lado. */
.ind-info-popover { .ind-info-popover {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
left: 50%; left: 50%;
transform: translateX(-50%); margin-left: -110px;
z-index: 20; z-index: 20;
width: 220px; width: 220px;
padding: var(--space-3); padding: var(--space-3);

View File

@ -11,6 +11,8 @@
/* Sidebar */ /* Sidebar */
.sidebar { .sidebar {
/* Fundo congelado escuro nos dois temas: a barra de rolagem acompanha. */
color-scheme: dark;
background: var(--bg-sidebar); background: var(--bg-sidebar);
border-right: 1px solid var(--sidebar-border); border-right: 1px solid var(--sidebar-border);
padding: var(--space-5) var(--space-3); padding: var(--space-5) var(--space-3);

View File

@ -151,6 +151,9 @@
} }
.lf-card__label { .lf-card__label {
/* Nome comprido sem espaço (ex.: URL) quebra dentro do cartão. */
max-width: 100%;
overflow-wrap: anywhere;
font-weight: 600; font-weight: 600;
font-size: 0.78rem; font-size: 0.78rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;

View File

@ -34,6 +34,8 @@
} }
.login-card { .login-card {
/* Card congelado escuro nos dois temas: controles nativos (autofill etc.) acompanham. */
color-scheme: dark;
width: 100%; width: 100%;
max-width: 380px; max-width: 380px;
background: var(--login-card-bg); background: var(--login-card-bg);
@ -185,17 +187,26 @@
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.btn-primary:hover { .btn-primary:hover:not(:disabled) {
background: var(--accent-hover); background: var(--accent-hover);
} }
/* Travado durante o envio do login (auth.js): sem isso parecia clicável. */
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.login-help { .login-help {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--login-text-muted); color: var(--login-text-muted);
text-align: center; text-align: center;
} }
.login-help a { /* "Inovação" era um link com href="#" (não levava a lugar nenhum): virou texto
simples, com o mesmo destaque visual de antes, sem cara de clicável. */
.login-help a,
.login-help__destaque {
color: var(--accent); color: var(--accent);
font-weight: 600; font-weight: 600;
} }

View File

@ -60,6 +60,36 @@
text-align: center; text-align: center;
} }
/* Erro de carga no lugar da mensagem de lista vazia (ver pidNcfEstadoVazio
em nao-conformidades.js): falha nunca pode parecer "nada encontrado". */
.ncf-empty.ncf-empty--erro {
color: var(--danger);
font-style: normal;
}
/* Erro de carga do Dashboard ocupa a grade de cards inteira. */
.ncf-dash-erro {
grid-column: 1 / -1;
margin: 0;
}
.pa-table td.ncf-ranking-erro {
color: var(--danger);
text-align: left;
}
/* Campos de data nativos (filtro de Vencimento) seguem o tema: o ícone e o
calendário do Chrome/Edge não ficam escuros sobre o fundo escuro. */
#ncf-col-filtro-vencimento-de,
#ncf-col-filtro-vencimento-ate {
color-scheme: dark;
}
:root[data-theme="light"] #ncf-col-filtro-vencimento-de,
:root[data-theme="light"] #ncf-col-filtro-vencimento-ate {
color-scheme: light;
}
/* Modificadores de .status-pill (base já vem de perfis-acesso.css, que /* Modificadores de .status-pill (base já vem de perfis-acesso.css, que
define .status-pill/.status-pill--ativo/.status-pill--inativo). */ define .status-pill/.status-pill--ativo/.status-pill--inativo). */
.status-pill--ok { .status-pill--ok {
@ -277,6 +307,7 @@
width: 320px; width: 320px;
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-4); padding: var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@ -365,6 +396,7 @@
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-variant-numeric: tabular-nums;
} }
.ncf-kpi__value--vazio { .ncf-kpi__value--vazio {
@ -436,6 +468,12 @@
/* Especificidade extra (elemento+classe) pra vencer .pa-table td, que já /* Especificidade extra (elemento+classe) pra vencer .pa-table td, que já
define color/font-weight próprios com a mesma especificidade de uma define color/font-weight próprios com a mesma especificidade de uma
classe sozinha. */ classe sozinha. */
/* Colunas numéricas dos rankings (Qtd./NC/Recl./Outros/Total) alinhadas
por dígito. */
.ncf-dash-ranking td {
font-variant-numeric: tabular-nums;
}
.pa-table td.ncf-num--nc { .pa-table td.ncf-num--nc {
color: var(--danger); color: var(--danger);
font-weight: 600; font-weight: 600;
@ -453,6 +491,7 @@
.ncf-clicavel { .ncf-clicavel {
cursor: pointer; cursor: pointer;
touch-action: manipulation;
} }
.ncf-clicavel:hover td { .ncf-clicavel:hover td {
@ -461,6 +500,7 @@
.ncf-card--clicavel { .ncf-card--clicavel {
cursor: pointer; cursor: pointer;
touch-action: manipulation;
text-align: left; text-align: left;
font: inherit; font: inherit;
transition: border-color var(--transition-fast), background var(--transition-fast); transition: border-color var(--transition-fast), background var(--transition-fast);
@ -497,6 +537,7 @@
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-variant-numeric: tabular-nums;
} }
.ncf-card--danger .ncf-card__value { .ncf-card--danger .ncf-card__value {
@ -523,6 +564,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
min-width: 0;
} }
.ncf-file-field__btn { .ncf-file-field__btn {
@ -541,9 +583,14 @@
clip: rect(0, 0, 0, 0); clip: rect(0, 0, 0, 0);
} }
/* Nome de arquivo longo termina em reticências em vez de empurrar o layout. */
.ncf-file-field__name { .ncf-file-field__name {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.ncf-import-form { .ncf-import-form {
@ -610,6 +657,7 @@
} }
.ncf-expand-btn { .ncf-expand-btn {
touch-action: manipulation;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -686,15 +734,19 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
/* overflow-wrap: anywhere: texto livre do Sigsistem com e-mail, URL ou
sequência longa sem espaço não estoura a largura do painel. */
.ncf-detail-field__value { .ncf-detail-field__value {
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
} }
.ncf-detail-panel__texto { .ncf-detail-panel__texto {
margin: 0 0 var(--space-4); margin: 0 0 var(--space-4);
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
} }
.ncf-detail-panel__loading { .ncf-detail-panel__loading {
@ -741,4 +793,5 @@
font-size: 0.88rem; font-size: 0.88rem;
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
} }

View File

@ -16,6 +16,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
/* Em tela estreita, busca e botões descem de linha em vez de estourar. */
flex-wrap: wrap;
} }
/* Filtro de status (Ativos/Inativos/Todos) da lista de usuários — mesmo /* Filtro de status (Ativos/Inativos/Todos) da lista de usuários — mesmo
@ -109,7 +111,10 @@
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; /* `auto` em vez de `hidden`: em tela estreita, tabelas largas (Usuários,
Ramais) rolam na horizontal em vez de cortar a coluna de ações. As duas
criam o mesmo contêiner de rolagem (cantos arredondados e sticky iguais). */
overflow-x: auto;
} }
.pa-table { .pa-table {
@ -144,6 +149,12 @@
background: var(--card-bg-hover); background: var(--card-bg-hover);
} }
/* Colunas numéricas (código, ramal): algarismos de mesma largura alinham
os valores na vertical. */
.pa-table .pa-num {
font-variant-numeric: tabular-nums;
}
.pa-name-link { .pa-name-link {
color: var(--accent); color: var(--accent);
font-weight: 600; font-weight: 600;
@ -314,7 +325,8 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.ua-permissions-row { /* Mesma especificidade da regra de 2 colunas acima, senão ela vence. */
.pa-field-row.ua-permissions-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@ -504,7 +516,9 @@
/* Tabs */ /* Tabs */
.pa-tabs { .pa-tabs {
display: flex; display: flex;
gap: var(--space-5); align-items: center;
flex-wrap: wrap;
column-gap: var(--space-5);
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
margin-bottom: var(--space-5); margin-bottom: var(--space-5);
} }
@ -518,11 +532,31 @@
margin-bottom: -1px; margin-bottom: -1px;
} }
.pa-tab {
transition: color var(--transition-fast);
}
.pa-tab:not(:disabled):not(.is-active):hover {
color: var(--text-primary);
}
.pa-tab.is-active { .pa-tab.is-active {
color: var(--accent); color: var(--accent);
border-color: var(--accent); border-color: var(--accent);
} }
/* Aba "Usuários do Escritório" de um perfil ainda não salvo (sem código
pra vincular). */
.pa-tab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pa-tab-hint {
font-size: 0.78rem;
color: var(--text-muted);
}
.pa-tab-panel[hidden] { .pa-tab-panel[hidden] {
display: none; display: none;
} }

View File

@ -114,6 +114,8 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
/* Rolar até o fim da lista não passa a rolar a página atrás do modal. */
overscroll-behavior: contain;
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
@ -152,6 +154,11 @@
background: var(--card-bg-hover); background: var(--card-bg-hover);
} }
/* Coluna Ramal: algarismos de mesma largura alinham os números. */
.ram-lookup-table td.ram-lookup-num {
font-variant-numeric: tabular-nums;
}
.ram-lookup-empty-row td { .ram-lookup-empty-row td {
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);

View File

@ -1,4 +1,8 @@
:root { :root {
/* Escuro é o padrão: avisa o navegador pra desenhar barra de rolagem,
calendário de input date etc. no esquema escuro também. */
color-scheme: dark;
/* Cores de marca */ /* Cores de marca */
--gold-500: #c9a13b; --gold-500: #c9a13b;
--gold-400: #d9bc6b; --gold-400: #d9bc6b;
@ -85,6 +89,7 @@
} }
:root[data-theme="light"] { :root[data-theme="light"] {
color-scheme: light;
--bg-canvas: #f3f1f7; --bg-canvas: #f3f1f7;
--bg-surface: #ffffff; --bg-surface: #ffffff;
--bg-surface-raised: #ffffff; --bg-surface-raised: #ffffff;

View File

@ -91,6 +91,18 @@
opacity: 1; opacity: 1;
} }
/* Tela de toque não tem hover: sem isto o botão de remover nunca aparecia. */
@media (hover: none) {
.widget-card__remove {
opacity: 1;
}
}
.widget-card__remove:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.widget-card__remove:hover { .widget-card__remove:hover {
color: var(--danger); color: var(--danger);
background: var(--bg-surface-raised); background: var(--bg-surface-raised);
@ -117,6 +129,8 @@
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--accent);
min-width: 68px; min-width: 68px;
/* Algarismos de largura fixa: as datas das linhas ficam alinhadas entre si. */
font-variant-numeric: tabular-nums;
} }
.widget-agenda-row__title { .widget-agenda-row__title {

View File

@ -55,6 +55,7 @@ function pidApplyAccessVisibility(me) {
async function pidInitAccess() { async function pidInitAccess() {
const me = await pidGetMe(); const me = await pidGetMe();
// null: ou já está indo pro login (401), ou pidRequireAuth já avisou do erro.
if (!me) return null; if (!me) return null;
const perfis = me.perfis || []; const perfis = me.perfis || [];

View File

@ -92,6 +92,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const manageAccountLideradosError = document.getElementById("manage-account-liderados-error"); const manageAccountLideradosError = document.getElementById("manage-account-liderados-error");
const manageAccountSaveBtn = document.getElementById("manage-account-save-btn"); const manageAccountSaveBtn = document.getElementById("manage-account-save-btn");
// Redefinida abaixo quando o modal Gerenciar Usuário existe na página.
let lideradosAlterados = () => false;
if (manageAccountBtn && manageAccountModal) { if (manageAccountBtn && manageAccountModal) {
function departamentosTexto(usuario) { function departamentosTexto(usuario) {
return (usuario.departamentos || []).map((d) => d.nome).join(", "); return (usuario.departamentos || []).map((d) => d.nome).join(", ");
@ -119,18 +122,40 @@ document.addEventListener("DOMContentLoaded", async () => {
secundariaValor: departamentosTexto, secundariaValor: departamentosTexto,
}); });
// Só há o que perder se o seletor de liderados foi carregado e a lista
// vinculada difere da que veio do servidor (me.liderados).
let lideradosCarregados = false;
function idsOrdenados(ids) {
return ids.map(Number).sort((a, b) => a - b).join(",");
}
lideradosAlterados = () => {
if (!lideradosCarregados) return false;
return idsOrdenados(seletorLiderados.getVinculadosIds()) !== idsOrdenados((me.liderados || []).map((u) => u.id));
};
manageAccountBtn.addEventListener("click", async () => { manageAccountBtn.addEventListener("click", async () => {
dropdown.hidden = true; dropdown.hidden = true;
manageAccountLideradosError.textContent = ""; manageAccountLideradosError.textContent = "";
lideradosCarregados = false;
if (me.lideranca) { if (me.lideranca) {
let usuarios;
try {
usuarios = (await pidApiRequest("/usuarios-resumo/")) || [];
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível abrir Gerenciar Usuário" });
return;
}
manageAccountLideradosField.hidden = false; manageAccountLideradosField.hidden = false;
manageAccountSaveBtn.hidden = false; manageAccountSaveBtn.hidden = false;
manageAccountModalCard.classList.add("modal-card--wide"); manageAccountModalCard.classList.add("modal-card--wide");
const candidatos = (await pidApiRequest("/usuarios-resumo/")) const candidatos = usuarios
.filter((u) => u.id !== me.id) .filter((u) => u.id !== me.id)
.slice() .slice()
.sort((a, b) => a.nome.localeCompare(b.nome)); .sort((a, b) => a.nome.localeCompare(b.nome));
seletorLiderados.setDados(candidatos, (me.liderados || []).map((u) => u.id)); seletorLiderados.setDados(candidatos, (me.liderados || []).map((u) => u.id));
lideradosCarregados = true;
} else { } else {
manageAccountLideradosField.hidden = true; manageAccountLideradosField.hidden = true;
manageAccountSaveBtn.hidden = true; manageAccountSaveBtn.hidden = true;
@ -139,18 +164,33 @@ document.addEventListener("DOMContentLoaded", async () => {
manageAccountModal.hidden = false; manageAccountModal.hidden = false;
}); });
manageAccountCloseBtn.addEventListener("click", () => { manageAccountCloseBtn.addEventListener("click", async () => {
if (lideradosAlterados()) {
const sair = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (!sair) return;
}
manageAccountModal.hidden = true; manageAccountModal.hidden = true;
}); });
manageAccountSaveBtn.addEventListener("click", async () => { manageAccountSaveBtn.addEventListener("click", async () => {
const liderados = seletorLiderados.getVinculadosIds(); const liderados = seletorLiderados.getVinculadosIds();
const textoOriginal = manageAccountSaveBtn.textContent;
manageAccountSaveBtn.disabled = true;
manageAccountSaveBtn.textContent = "Salvando…";
manageAccountLideradosError.textContent = "";
try { try {
const atualizados = await pidApiRequest("/me/liderados/", { method: "PATCH", body: { liderados } }); const atualizados = await pidApiRequest("/me/liderados/", { method: "PATCH", body: { liderados } });
me.liderados = atualizados; me.liderados = atualizados;
manageAccountModal.hidden = true; manageAccountModal.hidden = true;
} catch (e) { } catch (e) {
manageAccountLideradosError.textContent = e.message; manageAccountLideradosError.textContent = e.message;
} finally {
manageAccountSaveBtn.disabled = false;
manageAccountSaveBtn.textContent = textoOriginal;
} }
}); });
} }
@ -173,7 +213,21 @@ document.addEventListener("DOMContentLoaded", async () => {
passwordModal.hidden = true; passwordModal.hidden = true;
} }
manageAccountChangePasswordBtn.addEventListener("click", () => { // Campo oculto de usuário (só existe em portal.html): dá ao gerenciador de
// senhas do navegador a qual conta a senha nova pertence.
const pwdUsernameInput = document.getElementById("pwd-username");
if (pwdUsernameInput) pwdUsernameInput.value = me.username || "";
manageAccountChangePasswordBtn.addEventListener("click", async () => {
// "Alterar senha" também fecha Gerenciar Usuário: mesmo aviso do Fechar.
if (lideradosAlterados()) {
const sair = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (!sair) return;
}
manageAccountModal.hidden = true; manageAccountModal.hidden = true;
currentInput.value = ""; currentInput.value = "";
newInput.value = ""; newInput.value = "";
@ -202,14 +256,22 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
const textoOriginal = pwdSaveBtn.textContent;
pwdSaveBtn.disabled = true;
pwdSaveBtn.textContent = "Salvando…";
pwdError.textContent = "";
try { try {
await pidApiRequest("/me/senha/", { await pidApiRequest("/me/senha/", {
method: "POST", method: "POST",
body: { senha_atual: current, nova_senha: next }, body: { senha_atual: current, nova_senha: next },
}); });
closePasswordModal(); closePasswordModal();
await pidAlert("Senha alterada.", { titulo: "Senha alterada" });
} catch (e) { } catch (e) {
pwdError.textContent = e.message; pwdError.textContent = e.message;
} finally {
pwdSaveBtn.disabled = false;
pwdSaveBtn.textContent = textoOriginal;
} }
}); });
}); });

View File

@ -102,6 +102,20 @@ document.addEventListener("DOMContentLoaded", async () => {
let editingSecaoId = null; let editingSecaoId = null;
let editingAcessoId = null; let editingAcessoId = null;
let viewingAcessoId = null; let viewingAcessoId = null;
// Retratos dos formulários na abertura, pra só perguntar "Sair sem salvar"
// quando algo mudou de fato (inclusive imagem colada nas observações).
let retratoSecao = null;
let retratoAcesso = null;
const textoVazio = emptyEl ? emptyEl.textContent : "";
const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
function confirmarSairSemSalvar() {
return pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
}
function acessosDaSecao(secaoId) { function acessosDaSecao(secaoId) {
return acessos.filter((a) => a.secao === secaoId); return acessos.filter((a) => a.secao === secaoId);
@ -178,9 +192,17 @@ document.addEventListener("DOMContentLoaded", async () => {
secoes = fetchedSecoes; secoes = fetchedSecoes;
acessos = fetchedAcessos; acessos = fetchedAcessos;
} catch (e) { } catch (e) {
// Falha de carga não pode parecer "Nenhuma seção cadastrada ainda.".
secoes = []; secoes = [];
acessos = []; acessos = [];
render();
if (emptyEl) {
emptyEl.textContent = msgErroCarga;
emptyEl.hidden = false;
} }
return;
}
if (emptyEl) emptyEl.textContent = textoVazio;
render(); render();
} }
@ -188,9 +210,13 @@ document.addEventListener("DOMContentLoaded", async () => {
let perfisCache = []; let perfisCache = [];
// Lista enxuta da própria aplicação: `/api/perfis/` exige gerenciar
// permissões, e quem só edita Acessos Gerais não abria o modal.
async function ensurePerfisCache() { async function ensurePerfisCache() {
if (!perfisCache.length) { if (!perfisCache.length) {
perfisCache = (await pidFetchPerfis()).slice().sort((a, b) => a.nome.localeCompare(b.nome)); perfisCache = (await pidApiRequest("/acessos-gerais-secoes/perfis/"))
.slice()
.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
} }
} }
@ -216,22 +242,38 @@ document.addEventListener("DOMContentLoaded", async () => {
secaoFormNome.value = secao ? secao.nome : ""; secaoFormNome.value = secao ? secao.nome : "";
secaoFormError.textContent = ""; secaoFormError.textContent = "";
secaoSaveBtn.textContent = secao ? "Salvar" : "Adicionar"; secaoSaveBtn.textContent = secao ? "Salvar" : "Adicionar";
try {
await ensurePerfisCache(); await ensurePerfisCache();
} catch (e) {
pidAlert(`${e.message} Tente novamente; se persistir, contate a Inovação.`, { titulo: "Não foi possível abrir a seção" });
return;
}
renderSecaoPerfisChecklist(secao ? secao.perfis_restritos : []); renderSecaoPerfisChecklist(secao ? secao.perfis_restritos : []);
retratoSecao = retratoSecaoAtual();
secaoModal.hidden = false; secaoModal.hidden = false;
secaoFormNome.focus(); secaoFormNome.focus();
} }
function retratoSecaoAtual() {
return JSON.stringify([secaoFormNome.value, selectedSecaoPerfis().sort((a, b) => a - b)]);
}
function closeSecaoModal() { function closeSecaoModal() {
secaoModal.hidden = true; secaoModal.hidden = true;
editingSecaoId = null; editingSecaoId = null;
retratoSecao = null;
}
async function tentarFecharSecaoModal() {
if (retratoSecao !== null && retratoSecaoAtual() !== retratoSecao && !(await confirmarSairSemSalvar())) return;
closeSecaoModal();
} }
if (addSecaoBtn) addSecaoBtn.addEventListener("click", () => openSecaoModal(null)); if (addSecaoBtn) addSecaoBtn.addEventListener("click", () => openSecaoModal(null));
if (secaoCancelBtn) secaoCancelBtn.addEventListener("click", closeSecaoModal); if (secaoCancelBtn) secaoCancelBtn.addEventListener("click", tentarFecharSecaoModal);
if (secaoModal) { if (secaoModal) {
secaoModal.addEventListener("click", (event) => { secaoModal.addEventListener("click", (event) => {
if (event.target === secaoModal) closeSecaoModal(); if (event.target === secaoModal) tentarFecharSecaoModal();
}); });
} }
@ -240,10 +282,15 @@ document.addEventListener("DOMContentLoaded", async () => {
const nome = secaoFormNome.value.trim(); const nome = secaoFormNome.value.trim();
if (!nome) { if (!nome) {
secaoFormError.textContent = "Informe o nome da seção."; secaoFormError.textContent = "Informe o nome da seção.";
secaoFormNome.focus();
return; return;
} }
secaoFormError.textContent = ""; secaoFormError.textContent = "";
const payload = { nome, perfis_restritos: selectedSecaoPerfis() }; const payload = { nome, perfis_restritos: selectedSecaoPerfis() };
// Trava contra clique duplo (criaria a seção duas vezes).
const textoOriginal = secaoSaveBtn.textContent;
secaoSaveBtn.disabled = true;
secaoSaveBtn.textContent = editingSecaoId ? "Salvando…" : "Adicionando…";
try { try {
if (editingSecaoId) { if (editingSecaoId) {
await pidUpdateAcessoGeralSecao(editingSecaoId, payload); await pidUpdateAcessoGeralSecao(editingSecaoId, payload);
@ -254,6 +301,9 @@ document.addEventListener("DOMContentLoaded", async () => {
await refresh(); await refresh();
} catch (e) { } catch (e) {
secaoFormError.textContent = e.message; secaoFormError.textContent = e.message;
} finally {
secaoSaveBtn.disabled = false;
secaoSaveBtn.textContent = textoOriginal;
} }
}); });
} }
@ -274,10 +324,15 @@ document.addEventListener("DOMContentLoaded", async () => {
formObservacoes.innerHTML = acesso ? acesso.observacoes : ""; formObservacoes.innerHTML = acesso ? acesso.observacoes : "";
formError.textContent = ""; formError.textContent = "";
formSaveBtn.textContent = acesso ? "Salvar" : "Adicionar"; formSaveBtn.textContent = acesso ? "Salvar" : "Adicionar";
retratoAcesso = retratoAcessoAtual();
formModal.hidden = false; formModal.hidden = false;
formNome.focus(); formNome.focus();
} }
function retratoAcessoAtual() {
return JSON.stringify([formNome.value, formUrl.value, formUsuario.value, formSenha.value, formObservacoes.innerHTML]);
}
// --- Observações ricas (contenteditable + imagens embutidas via colar/arrastar) --- // --- Observações ricas (contenteditable + imagens embutidas via colar/arrastar) ---
function observacoesValue() { function observacoesValue() {
@ -309,7 +364,7 @@ document.addEventListener("DOMContentLoaded", async () => {
function insertImageFile(file, atPoint) { function insertImageFile(file, atPoint) {
if (!file || !file.type.startsWith("image/")) return; if (!file || !file.type.startsWith("image/")) return;
if (file.size > PID_AG_IMAGE_MAX_BYTES) { if (file.size > PID_AG_IMAGE_MAX_BYTES) {
formError.textContent = "A imagem deve ter no máximo 2MB."; formError.textContent = "A imagem deve ter no máximo 2 MB.";
return; return;
} }
formError.textContent = ""; formError.textContent = "";
@ -347,12 +402,18 @@ document.addEventListener("DOMContentLoaded", async () => {
formModal.hidden = true; formModal.hidden = true;
editingAcessoId = null; editingAcessoId = null;
formSecaoId = null; formSecaoId = null;
retratoAcesso = null;
} }
if (formCancelBtn) formCancelBtn.addEventListener("click", closeFormModal); async function tentarFecharFormModal() {
if (retratoAcesso !== null && retratoAcessoAtual() !== retratoAcesso && !(await confirmarSairSemSalvar())) return;
closeFormModal();
}
if (formCancelBtn) formCancelBtn.addEventListener("click", tentarFecharFormModal);
if (formModal) { if (formModal) {
formModal.addEventListener("click", (event) => { formModal.addEventListener("click", (event) => {
if (event.target === formModal) closeFormModal(); if (event.target === formModal) tentarFecharFormModal();
}); });
} }
@ -361,6 +422,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const nome = formNome.value.trim(); const nome = formNome.value.trim();
if (!nome) { if (!nome) {
formError.textContent = "Informe o nome do acesso."; formError.textContent = "Informe o nome do acesso.";
formNome.focus();
return; return;
} }
formError.textContent = ""; formError.textContent = "";
@ -374,6 +436,10 @@ document.addEventListener("DOMContentLoaded", async () => {
}; };
if (!editingAcessoId) payload.secao = formSecaoId; if (!editingAcessoId) payload.secao = formSecaoId;
// Trava contra clique duplo (observações com imagem deixam o envio lento).
const textoOriginal = formSaveBtn.textContent;
formSaveBtn.disabled = true;
formSaveBtn.textContent = editingAcessoId ? "Salvando…" : "Adicionando…";
try { try {
if (editingAcessoId) { if (editingAcessoId) {
await pidUpdateAcessoGeral(editingAcessoId, payload); await pidUpdateAcessoGeral(editingAcessoId, payload);
@ -384,6 +450,9 @@ document.addEventListener("DOMContentLoaded", async () => {
await refresh(); await refresh();
} catch (e) { } catch (e) {
formError.textContent = e.message; formError.textContent = e.message;
} finally {
formSaveBtn.disabled = false;
formSaveBtn.textContent = textoOriginal;
} }
}); });
} }
@ -460,7 +529,15 @@ document.addEventListener("DOMContentLoaded", async () => {
const acesso = acessos.find((a) => a.id === viewingAcessoId); const acesso = acessos.find((a) => a.id === viewingAcessoId);
if (!acesso) return; if (!acesso) return;
if (!(await pidConfirm(`Excluir o acesso "${acesso.nome}"?`, { perigoso: true }))) return; if (!(await pidConfirm(`Excluir o acesso "${acesso.nome}"?`, { perigoso: true }))) return;
viewDeleteBtn.disabled = true;
try {
await pidDeleteAcessoGeral(acesso.id); await pidDeleteAcessoGeral(acesso.id);
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível excluir o acesso" });
return;
} finally {
viewDeleteBtn.disabled = false;
}
closeViewModal(); closeViewModal();
await refresh(); await refresh();
}); });
@ -468,8 +545,15 @@ document.addEventListener("DOMContentLoaded", async () => {
// --- Ações na grade (seções e linhas) --- // --- Ações na grade (seções e linhas) ---
async function swapOrdem(a, b) { // Na falha, uma das duas gravações pode ter passado: recarrega pra tela
// mostrar a ordem real do servidor.
async function swapOrdem(a, b, botao) {
botao.disabled = true;
try {
await Promise.all([pidUpdateAcessoGeral(a.id, { ordem: b.ordem }), pidUpdateAcessoGeral(b.id, { ordem: a.ordem })]); await Promise.all([pidUpdateAcessoGeral(a.id, { ordem: b.ordem }), pidUpdateAcessoGeral(b.id, { ordem: a.ordem })]);
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível mover o acesso" });
}
await refresh(); await refresh();
} }
@ -487,7 +571,14 @@ document.addEventListener("DOMContentLoaded", async () => {
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 && (await pidConfirm(`Excluir a seção "${secao.nome}" e todos os seus acessos?`, { perigoso: true }))) { if (secao && (await pidConfirm(`Excluir a seção "${secao.nome}" e todos os seus acessos?`, { perigoso: true }))) {
secaoDeleteBtn.disabled = true;
try {
await pidDeleteAcessoGeralSecao(id); await pidDeleteAcessoGeralSecao(id);
} catch (e) {
secaoDeleteBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível excluir a seção" });
return;
}
await refresh(); await refresh();
} }
return; return;
@ -515,7 +606,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!acesso) return; if (!acesso) return;
const irmaos = acessosDaSecao(acesso.secao); const irmaos = acessosDaSecao(acesso.secao);
const index = irmaos.findIndex((a) => a.id === id); const index = irmaos.findIndex((a) => a.id === id);
if (index > 0) await swapOrdem(irmaos[index], irmaos[index - 1]); if (index > 0) await swapOrdem(irmaos[index], irmaos[index - 1], upBtn);
return; return;
} }
@ -526,7 +617,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!acesso) return; if (!acesso) return;
const irmaos = acessosDaSecao(acesso.secao); const irmaos = acessosDaSecao(acesso.secao);
const index = irmaos.findIndex((a) => a.id === id); const index = irmaos.findIndex((a) => a.id === id);
if (index !== -1 && index < irmaos.length - 1) await swapOrdem(irmaos[index], irmaos[index + 1]); if (index !== -1 && index < irmaos.length - 1) await swapOrdem(irmaos[index], irmaos[index + 1], downBtn);
} }
}); });
@ -591,12 +682,18 @@ document.addEventListener("DOMContentLoaded", async () => {
const updates = reordered.map((acesso, index) => ({ acesso, index })).filter(({ acesso, index }) => acesso.ordem !== index); const updates = reordered.map((acesso, index) => ({ acesso, index })).filter(({ acesso, index }) => acesso.ordem !== index);
if (!updates.length) return; if (!updates.length) return;
acessos = acessos.map((a) => { // Reordena localmente as posições da seção no array (render() segue a
const update = updates.find((u) => u.acesso.id === a.id); // ordem do array, não o campo `ordem`), pra linha mudar na hora.
return update ? { ...a, ordem: update.index } : a; const fila = reordered.map((a, index) => ({ ...a, ordem: index }));
}); let k = 0;
acessos = acessos.map((a) => (a.secao === fromAcesso.secao ? fila[k++] : a));
render(); render();
try {
await Promise.all(updates.map(({ acesso, index }) => pidUpdateAcessoGeral(acesso.id, { ordem: index }))); await Promise.all(updates.map(({ acesso, index }) => pidUpdateAcessoGeral(acesso.id, { ordem: index })));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível reordenar os acessos" });
}
// Sempre recarrega: na falha, desfaz a ordem que a tela mostrou antes da hora.
await refresh(); await refresh();
}); });

View File

@ -112,14 +112,26 @@ function pidAjudaAplicacaoModal() {
async function pidFecharAjudaAplicacaoModal() { async function pidFecharAjudaAplicacaoModal() {
const modal = document.getElementById("ajuda-aplicacao-modal"); const modal = document.getElementById("ajuda-aplicacao-modal");
if (!modal) return; if (!modal) return;
if (modal.dataset.editing === "true") { if (modal.dataset.editing === "true" && pidAjudaTemAlteracao(modal)) {
const confirmado = await pidConfirm("Sair sem salvar as alterações?", { perigoso: true }); const confirmado = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (!confirmado) return; if (!confirmado) return;
} }
modal.dataset.editing = "false"; modal.dataset.editing = "false";
modal.hidden = true; modal.hidden = true;
} }
/** Compara o HTML atual do editor com o da abertura da edição (guardado em
* `modal.dataset.textoOriginal` por entrarEdicao, já depois de o navegador
* normalizar o HTML), pra só avisar de "sair sem salvar" quando algo mudou. */
function pidAjudaTemAlteracao(modal) {
const editor = modal.querySelector(".ajuda-modal__editor");
return editor.innerHTML !== (modal.dataset.textoOriginal || "");
}
function pidAjudaEditorValue(editor) { function pidAjudaEditorValue(editor) {
const hasImage = editor.querySelector("img") !== null; const hasImage = editor.querySelector("img") !== null;
const hasText = editor.textContent.trim() !== ""; const hasText = editor.textContent.trim() !== "";
@ -139,7 +151,7 @@ async function pidAbrirAjudaAplicacaoModal(appKey, tituloApp) {
modal.dataset.editing = "false"; modal.dataset.editing = "false";
titulo.textContent = `Mais informações — ${tituloApp}`; titulo.textContent = `Mais informações — ${tituloApp}`;
textoEl.textContent = "Carregando..."; textoEl.textContent = "Carregando…";
textoEl.hidden = false; textoEl.hidden = false;
editor.hidden = true; editor.hidden = true;
editorHint.hidden = true; editorHint.hidden = true;
@ -179,6 +191,8 @@ async function pidAbrirAjudaAplicacaoModal(appKey, tituloApp) {
function entrarEdicao() { function entrarEdicao() {
modal.dataset.editing = "true"; modal.dataset.editing = "true";
editor.innerHTML = ajuda.texto || ""; editor.innerHTML = ajuda.texto || "";
// Lido de volta do editor (não de ajuda.texto): o navegador pode normalizar o HTML ao atribuir.
modal.dataset.textoOriginal = editor.innerHTML;
textoEl.hidden = true; textoEl.hidden = true;
editor.hidden = false; editor.hidden = false;
editorHint.hidden = false; editorHint.hidden = false;
@ -191,10 +205,21 @@ async function pidAbrirAjudaAplicacaoModal(appKey, tituloApp) {
editarBtn.onclick = entrarEdicao; editarBtn.onclick = entrarEdicao;
cancelarBtn.onclick = async () => { cancelarBtn.onclick = async () => {
if (await pidConfirm("Sair sem salvar as alterações?", { perigoso: true })) renderVisualizacao(); if (!pidAjudaTemAlteracao(modal)) {
renderVisualizacao();
return;
}
const confirmado = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (confirmado) renderVisualizacao();
}; };
salvarBtn.onclick = async () => { salvarBtn.onclick = async () => {
if (salvarBtn.disabled) return;
salvarBtn.disabled = true; salvarBtn.disabled = true;
salvarBtn.textContent = "Salvando…";
try { try {
ajuda = await pidApiRequest(`/ajuda-aplicacoes/${encodeURIComponent(appKey)}/`, { ajuda = await pidApiRequest(`/ajuda-aplicacoes/${encodeURIComponent(appKey)}/`, {
method: "PATCH", method: "PATCH",
@ -206,6 +231,7 @@ async function pidAbrirAjudaAplicacaoModal(appKey, tituloApp) {
erro.hidden = false; erro.hidden = false;
} finally { } finally {
salvarBtn.disabled = false; salvarBtn.disabled = false;
salvarBtn.textContent = "Salvar";
} }
}; };

View File

@ -26,12 +26,15 @@ function pidEscapeHtml(texto) {
return String(texto).replace(/[&<>"']/g, (ch) => PID_ESCAPE_HTML_MAPA[ch]); return String(texto).replace(/[&<>"']/g, (ch) => PID_ESCAPE_HTML_MAPA[ch]);
} }
// Mensagem genérica já diz o que fazer em seguida, pra quem lê não ficar sem saída.
const PID_ERRO_GENERICO = "Erro ao processar a solicitação. Tente novamente; se persistir, contate a Inovação.";
function pidErrorMessageFrom(data) { function pidErrorMessageFrom(data) {
if (!data) return "Erro ao processar a solicitação."; if (!data) return PID_ERRO_GENERICO;
if (typeof data === "string") return data; if (typeof data === "string") return data;
if (typeof data.detail === "string") return data.detail; if (typeof data.detail === "string") return data.detail;
const values = Object.values(data).flat().filter((v) => typeof v === "string"); const values = Object.values(data).flat().filter((v) => typeof v === "string");
return values.length ? values.join(" ") : "Erro ao processar a solicitação."; return values.length ? values.join(" ") : PID_ERRO_GENERICO;
} }
/** /**

View File

@ -1,7 +1,16 @@
/** 401: pidApiRequest já redireciona pro login e devolve null (sem lançar).
* Qualquer outro erro (servidor fora, 500) lança: aqui avisa o usuário, senão
* a página ficava sem menu e sem explicação nenhuma. Roda uma única vez por
* página (pidGetMe cacheia a promise), então o aviso não se repete. */
async function pidRequireAuth() { async function pidRequireAuth() {
try { try {
return await pidApiRequest("/me/"); return await pidApiRequest("/me/");
} catch (e) { } catch (e) {
if (typeof pidAlert === "function") {
pidAlert("Não foi possível carregar seus dados. Recarregue a página; se persistir, contate a Inovação.", {
titulo: "Não foi possível carregar",
});
}
return null; return null;
} }
} }
@ -49,7 +58,9 @@ document.addEventListener("DOMContentLoaded", async () => {
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const username = userInput.value.trim(); const username = userInput.value.trim();
const password = passInput.value.trim(); // Senha sem trim: a troca de senha grava espaços do começo/fim, e com
// trim aqui essa senha deixava de funcionar no login.
const password = passInput.value;
if (!username || !password) { if (!username || !password) {
errorEl.textContent = "Informe login e senha para continuar."; errorEl.textContent = "Informe login e senha para continuar.";
@ -71,7 +82,12 @@ document.addEventListener("DOMContentLoaded", async () => {
? pidPlayLoginIntroAniversario ? pidPlayLoginIntroAniversario
: pidPlayLoginIntro; : pidPlayLoginIntro;
tocaIntro(() => { tocaIntro(() => {
// Armazenamento bloqueado não pode impedir a navegação: só perde a revelação animada.
try {
sessionStorage.setItem("pid_reveal_portal", "1"); sessionStorage.setItem("pid_reveal_portal", "1");
} catch (e) {
// segue sem a animação de revelação
}
window.location.href = "portal.html"; window.location.href = "portal.html";
}); });
} catch (e) { } catch (e) {

View File

@ -110,15 +110,32 @@ document.addEventListener("DOMContentLoaded", async () => {
modalEhEventoCheckbox.addEventListener("change", updateEhEventoFieldsVisibility); modalEhEventoCheckbox.addEventListener("change", updateEhEventoFieldsVisibility);
let categorias = await pidFetchCategoriasEvento(); // Sem categorias o calendário continua funcionando; o select avisa que a
// lista não veio, em vez de parecer que não há categoria cadastrada.
let categorias = [];
let categoriasFalharam = false;
try {
categorias = await pidFetchCategoriasEvento();
} catch (e) {
categoriasFalharam = true;
}
function popularSelectCategorias(selecionarId) { function popularSelectCategorias(selecionarId) {
const rotuloVazio = categoriasFalharam ? "Nenhuma (categorias indisponíveis, recarregue a página)" : "Nenhuma";
modalCategoriaSelect.innerHTML = modalCategoriaSelect.innerHTML =
'<option value="">Nenhuma</option>' + `<option value="">${rotuloVazio}</option>` +
categorias.map((c) => `<option value="${pidEscapeHtml(c.id)}">${pidEscapeHtml(c.nome)}</option>`).join(""); categorias.map((c) => `<option value="${pidEscapeHtml(c.id)}">${pidEscapeHtml(c.nome)}</option>`).join("");
if (selecionarId) modalCategoriaSelect.value = String(selecionarId); if (selecionarId) modalCategoriaSelect.value = String(selecionarId);
} }
function confirmarSairSemSalvar() {
return pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
}
popularSelectCategorias(); popularSelectCategorias();
const holidayModal = document.getElementById("ic-holiday-modal"); const holidayModal = document.getElementById("ic-holiday-modal");
@ -145,35 +162,56 @@ document.addEventListener("DOMContentLoaded", async () => {
modalVisibilitySelect.addEventListener("change", updateDepartmentFieldVisibility); modalVisibilitySelect.addEventListener("change", updateDepartmentFieldVisibility);
// Retrato do modal de categoria na abertura (só pergunta ao sair se mudou).
let retratoCategoria = null;
function retratoCategoriaAtual() {
return JSON.stringify([categoriaNomeInput.value, categoriaCorInput.value]);
}
async function tentarFecharCategoriaModal() {
if (retratoCategoria !== null && retratoCategoriaAtual() !== retratoCategoria && !(await confirmarSairSemSalvar())) return;
categoriaModal.hidden = true;
retratoCategoria = null;
}
modalCategoriaAddBtn.addEventListener("click", () => { modalCategoriaAddBtn.addEventListener("click", () => {
categoriaNomeInput.value = ""; categoriaNomeInput.value = "";
categoriaCorInput.value = "#7c4dff"; categoriaCorInput.value = "#7c4dff";
categoriaError.textContent = ""; categoriaError.textContent = "";
retratoCategoria = retratoCategoriaAtual();
categoriaModal.hidden = false; categoriaModal.hidden = false;
categoriaNomeInput.focus(); categoriaNomeInput.focus();
}); });
categoriaCancelBtn.addEventListener("click", () => { categoriaCancelBtn.addEventListener("click", tentarFecharCategoriaModal);
categoriaModal.hidden = true;
});
categoriaModal.addEventListener("click", (event) => { categoriaModal.addEventListener("click", (event) => {
if (event.target === categoriaModal) categoriaModal.hidden = true; if (event.target === categoriaModal) tentarFecharCategoriaModal();
}); });
categoriaSaveBtn.addEventListener("click", async () => { categoriaSaveBtn.addEventListener("click", async () => {
const nome = categoriaNomeInput.value.trim(); const nome = categoriaNomeInput.value.trim();
if (!nome) { if (!nome) {
categoriaError.textContent = "Informe o nome da categoria."; categoriaError.textContent = "Informe o nome da categoria.";
categoriaNomeInput.focus();
return; return;
} }
// Trava contra clique duplo (criaria a categoria duas vezes).
const textoOriginal = categoriaSaveBtn.textContent;
categoriaSaveBtn.disabled = true;
categoriaSaveBtn.textContent = "Salvando…";
try { try {
const nova = await pidCreateCategoriaEvento({ nome, cor: categoriaCorInput.value }); const nova = await pidCreateCategoriaEvento({ nome, cor: categoriaCorInput.value });
categorias.push(nova); categorias.push(nova);
popularSelectCategorias(nova.id); popularSelectCategorias(nova.id);
categoriaModal.hidden = true; categoriaModal.hidden = true;
retratoCategoria = null;
} catch (e) { } catch (e) {
categoriaError.textContent = e.message; categoriaError.textContent = e.message;
} finally {
categoriaSaveBtn.disabled = false;
categoriaSaveBtn.textContent = textoOriginal;
} }
}); });
@ -226,6 +264,24 @@ document.addEventListener("DOMContentLoaded", async () => {
let viewMonth = today.getMonth(); let viewMonth = today.getMonth();
let editingEventId = null; let editingEventId = null;
let events = []; let events = [];
// Retrato do formulário de compromisso na abertura.
let retratoCompromisso = null;
function retratoCompromissoAtual() {
return JSON.stringify([
modalTitleInput.value,
modalDateInput.value,
modalTimeInput.value,
modalVisibilitySelect.value,
modalDepartmentSelect.value,
modalReminderSelect.value,
modalCategoriaSelect.value,
modalLocalInput.value,
modalModalidadeSelect.value,
modalDescricaoInput.value,
modalEhEventoCheckbox.checked,
]);
}
function openModal(prefillDate, existingEvent) { function openModal(prefillDate, existingEvent) {
if (existingEvent) { if (existingEvent) {
@ -261,12 +317,19 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
updateEhEventoFieldsVisibility(); updateEhEventoFieldsVisibility();
modalError.textContent = ""; modalError.textContent = "";
retratoCompromisso = retratoCompromissoAtual();
modal.hidden = false; modal.hidden = false;
modalTitleInput.focus(); modalTitleInput.focus();
} }
function closeModal() { function closeModal() {
modal.hidden = true; modal.hidden = true;
retratoCompromisso = null;
}
async function tentarFecharModal() {
if (retratoCompromisso !== null && retratoCompromissoAtual() !== retratoCompromisso && !(await confirmarSairSemSalvar())) return;
closeModal();
} }
function openViewModal(ev) { function openViewModal(ev) {
@ -370,7 +433,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
modal.addEventListener("click", (event) => { modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal(); if (event.target === modal) tentarFecharModal();
}); });
viewModal.addEventListener("click", (event) => { viewModal.addEventListener("click", (event) => {
@ -385,16 +448,32 @@ document.addEventListener("DOMContentLoaded", async () => {
if (event.target === dayModal) dayModal.hidden = true; if (event.target === dayModal) dayModal.hidden = true;
}); });
// Nunca lança: na falha avisa e mantém a lista anterior, pra grade
// continuar desenhada (e um save que já deu certo não parecer erro).
async function reloadEvents() { async function reloadEvents() {
try {
events = await pidFetchEvents(); events = await pidFetchEvents();
return true;
} catch (e) {
await pidAlert(`${e.message} Recarregue a página; se persistir, contate a Inovação.`, {
titulo: "Não foi possível carregar os compromissos",
});
return false;
}
} }
const feriadosPorAno = new Map(); const feriadosPorAno = new Map();
// Feriado é complemento: se /api/feriados/ falhar, a grade sai sem eles
// em vez de sumir. A falha não fica em cache (tenta de novo na próxima).
async function carregarFeriados(ano) { async function carregarFeriados(ano) {
if (!feriadosPorAno.has(ano)) { if (!feriadosPorAno.has(ano)) {
try {
const lista = await pidApiRequest(`/feriados/?ano=${ano}`); const lista = await pidApiRequest(`/feriados/?ano=${ano}`);
feriadosPorAno.set(ano, new Map((lista || []).map((f) => [f.data, f.nome]))); feriadosPorAno.set(ano, new Map((lista || []).map((f) => [f.data, f.nome])));
} catch (e) {
return new Map();
}
} }
return feriadosPorAno.get(ano); return feriadosPorAno.get(ano);
} }
@ -415,10 +494,12 @@ document.addEventListener("DOMContentLoaded", async () => {
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate(); const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
const isCurrentMonth = viewYear === today.getFullYear() && viewMonth === today.getMonth(); const isCurrentMonth = viewYear === today.getFullYear() && viewMonth === today.getMonth();
titleEl.textContent = new Date(viewYear, viewMonth, 1).toLocaleDateString("pt-BR", { // Só a primeira letra maiúscula ("Setembro de 2026", não "Setembro De 2026").
const tituloMes = new Date(viewYear, viewMonth, 1).toLocaleDateString("pt-BR", {
month: "long", month: "long",
year: "numeric", year: "numeric",
}); });
titleEl.textContent = tituloMes.charAt(0).toUpperCase() + tituloMes.slice(1);
const cells = []; const cells = [];
for (let i = firstWeekday - 1; i >= 0; i -= 1) { for (let i = firstWeekday - 1; i >= 0; i -= 1) {
@ -493,19 +574,23 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
newBtn.addEventListener("click", () => openModal(null, null)); newBtn.addEventListener("click", () => openModal(null, null));
modalCancelBtn.addEventListener("click", closeModal); modalCancelBtn.addEventListener("click", tentarFecharModal);
modalDeleteBtn.addEventListener("click", async () => { modalDeleteBtn.addEventListener("click", async () => {
if (!editingEventId) return; if (!editingEventId) return;
if (await pidConfirm("Excluir este compromisso?", { perigoso: true })) { if (await pidConfirm("Excluir este compromisso?", { perigoso: true })) {
modalDeleteBtn.disabled = true;
try { try {
await pidDeleteEvent(editingEventId); await pidDeleteEvent(editingEventId);
} catch (e) {
modalError.textContent = e.message;
return;
} finally {
modalDeleteBtn.disabled = false;
}
closeModal(); closeModal();
await reloadEvents(); await reloadEvents();
render(); render();
} catch (e) {
modalError.textContent = e.message;
}
} }
}); });
@ -516,6 +601,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const visibilidade = modalVisibilitySelect.value; const visibilidade = modalVisibilitySelect.value;
if (!titulo || !data) { if (!titulo || !data) {
modalError.textContent = "Informe o título e a data do compromisso."; modalError.textContent = "Informe o título e a data do compromisso.";
(titulo ? modalDateInput : modalTitleInput).focus();
return; return;
} }
if (visibilidade === "departamento" && !modalDepartmentSelect.value) { if (visibilidade === "departamento" && !modalDepartmentSelect.value) {
@ -537,18 +623,26 @@ document.addEventListener("DOMContentLoaded", async () => {
descricao: modalDescricaoInput.value.trim(), descricao: modalDescricaoInput.value.trim(),
}; };
// Trava contra clique duplo (criaria o compromisso duas vezes).
const textoOriginal = modalSaveBtn.textContent;
modalSaveBtn.disabled = true;
modalSaveBtn.textContent = "Salvando…";
try { try {
if (editingEventId) { if (editingEventId) {
await pidUpdateEvent(editingEventId, payload); await pidUpdateEvent(editingEventId, payload);
} else { } else {
await pidCreateEvent(payload); await pidCreateEvent(payload);
} }
} catch (e) {
modalError.textContent = e.message;
return;
} finally {
modalSaveBtn.disabled = false;
modalSaveBtn.textContent = textoOriginal;
}
closeModal(); closeModal();
await reloadEvents(); await reloadEvents();
render(); render();
} catch (e) {
modalError.textContent = e.message;
}
}); });
prevBtn.addEventListener("click", () => { prevBtn.addEventListener("click", () => {

View File

@ -255,6 +255,101 @@ function pidConcRazaoAtePadrao(minimo) {
return minimo && minimo > padrao ? minimo : padrao; return minimo && minimo > padrao ? minimo : padrao;
} }
/** Campo "Razão até" (`.conc-data-field`): texto digitável em dd/mm/aaaa
* sobre um `<input type="date">` escondido, que guarda o valor ISO e segue
* sendo o lido pelo resto da tela (`.value`/`.min`/`.max`). O campo nativo
* aceita digitar 31/09 mas só devolve `value` vazio, sem dizer o que foi
* digitado; aqui, dia além do fim do mês vira o último dia (pedido do
* usuário: 31/09/2026 → 30/09/2026), sem aviso (também pedido dele; o
* aviso abaixo do campo fica só para erro). O botão abre o calendário
* nativo. Devolve `definir(iso)` (preencher por código) e `validar()` (erro
* do texto digitado, "" se válido ou vazio). */
function pidConcCampoData(dataInput) {
const campo = dataInput.closest(".conc-data-field");
const textoInput = campo.querySelector("input[type='text']");
const avisoEl = campo.parentElement.querySelector(".conc-data-field__aviso");
const dois = (n) => String(n).padStart(2, "0");
function avisar(mensagem, erro) {
avisoEl.textContent = mensagem;
avisoEl.classList.toggle("is-erro", !!erro);
avisoEl.hidden = !mensagem;
}
function definir(iso) {
dataInput.value = iso || "";
textoInput.value = dataInput.value ? pidConcData(dataInput.value) : "";
avisar("");
}
/** Lê o texto; com `completo`, texto parcial conta como erro. */
function interpretar(completo) {
const digitos = textoInput.value.replace(/\D/g, "");
dataInput.value = "";
if (!digitos) {
avisar("");
return "";
}
if (digitos.length < 8) {
if (!completo) {
avisar("");
return "";
}
const erro = "Data incompleta: use dd/mm/aaaa.";
avisar(erro, true);
return erro;
}
const dia = Number(digitos.slice(0, 2));
const mes = Number(digitos.slice(2, 4));
const ano = Number(digitos.slice(4, 8));
if (dia < 1 || mes < 1 || mes > 12 || ano < 1900) {
const erro = "Data inválida: confira o dia e o mês (dd/mm/aaaa).";
avisar(erro, true);
return erro;
}
const ultimoDia = new Date(ano, mes, 0).getDate();
definir(`${ano}-${dois(mes)}-${dois(Math.min(dia, ultimoDia))}`);
return "";
}
textoInput.addEventListener("input", (evento) => {
// Máscara dd/mm/aaaa; apagando, não recoloca a barra.
if (!evento.inputType || !evento.inputType.startsWith("delete")) {
const d = textoInput.value.replace(/\D/g, "").slice(0, 8);
textoInput.value = [d.slice(0, 2), d.slice(2, 4), d.slice(4)].filter(Boolean).join("/");
}
interpretar(false);
});
textoInput.addEventListener("blur", () => interpretar(true));
dataInput.addEventListener("change", () => definir(dataInput.value));
campo.querySelector(".conc-data-field__btn").addEventListener("click", () => {
try {
dataInput.showPicker();
} catch (erro) {
textoInput.focus(); // navegador sem showPicker(): fica a digitação
}
});
return { definir, validar: () => interpretar(true), focar: () => textoInput.focus() };
}
/** Lê `#conciliacao-<id>[?filtros]` da URL: `{ id, params }` ou `null`. */
function pidConcLeHash() {
const partes = window.location.hash.match(/^#conciliacao-(\d+)(?:\?(.*))?$/);
if (!partes) return null;
return { id: Number(partes[1]), params: new URLSearchParams(partes[2] || "") };
}
/** Busca sem redesenhar a lista a cada tecla: roda `funcao` só depois de
* `ms` sem digitação (a lista de fornecedores tem centenas de linhas). */
function pidConcAtrasado(funcao, ms = 150) {
let timer = null;
return () => {
clearTimeout(timer);
timer = setTimeout(funcao, ms);
};
}
/** Barra de progresso de "Nova Conciliação"/"Atualizar razão" (mesmo padrão /** Barra de progresso de "Nova Conciliação"/"Atualizar razão" (mesmo padrão
* de `pidDcIniciarProgressoSimulado` do Relatório Contábil): o processamento * de `pidDcIniciarProgressoSimulado` do Relatório Contábil): o processamento
* é uma requisição síncrona, sem percentual real. Sobe rápido e desacelera * é uma requisição síncrona, sem percentual real. Sobe rápido e desacelera
@ -358,6 +453,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const arquivoInput = document.getElementById("conc-form-arquivo"); const arquivoInput = document.getElementById("conc-form-arquivo");
const arquivoNomeEl = document.getElementById("conc-form-arquivo-nome"); const arquivoNomeEl = document.getElementById("conc-form-arquivo-nome");
const periodoFimInput = document.getElementById("conc-form-periodo-fim"); const periodoFimInput = document.getElementById("conc-form-periodo-fim");
const periodoFimCampo = pidConcCampoData(periodoFimInput);
const formErroEl = document.getElementById("conc-form-erro"); const formErroEl = document.getElementById("conc-form-erro");
const processarBtn = document.getElementById("conc-form-processar-btn"); const processarBtn = document.getElementById("conc-form-processar-btn");
const listaBody = document.getElementById("conc-lista-body"); const listaBody = document.getElementById("conc-lista-body");
@ -375,7 +471,7 @@ document.addEventListener("DOMContentLoaded", async () => {
empresaEl.className = "conc-form__empresa"; empresaEl.className = "conc-form__empresa";
arquivoInput.value = ""; arquivoInput.value = "";
arquivoNomeEl.textContent = "Nenhum arquivo selecionado"; arquivoNomeEl.textContent = "Nenhum arquivo selecionado";
periodoFimInput.value = pidConcRazaoAtePadrao(); periodoFimCampo.definir(""); // em branco: o contador informa (pedido do usuário)
periodoFimInput.max = pidConcIsoLocal(new Date()); periodoFimInput.max = pidConcIsoLocal(new Date());
formErroEl.textContent = ""; formErroEl.textContent = "";
nomeEmpresaResolvido = null; nomeEmpresaResolvido = null;
@ -419,7 +515,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
const consulta = ++consultaNomeAtual; const consulta = ++consultaNomeAtual;
empresaEl.textContent = "Buscando no Questor..."; empresaEl.textContent = "Buscando no Questor…";
empresaEl.className = "conc-form__empresa is-carregando"; empresaEl.className = "conc-form__empresa is-carregando";
try { try {
const dados = await pidApiRequest(`/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=${encodeURIComponent(codigo)}`); const dados = await pidApiRequest(`/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=${encodeURIComponent(codigo)}`);
@ -445,14 +541,19 @@ document.addEventListener("DOMContentLoaded", async () => {
const arquivo = arquivoInput.files[0]; const arquivo = arquivoInput.files[0];
if (!codigo) { if (!codigo) {
formErroEl.textContent = "Informe o código da empresa."; formErroEl.textContent = "Informe o código da empresa.";
codigoInput.focus();
return; return;
} }
if (!arquivo) { if (!arquivo) {
formErroEl.textContent = "Selecione o arquivo do razão."; formErroEl.textContent = "Selecione o arquivo do razão.";
return; return;
} }
if (!periodoFimInput.value) { const erroPeriodoFim = periodoFimCampo.validar();
formErroEl.textContent = "Informe até quando o razão foi exportado (\"Razão até\")."; if (erroPeriodoFim || !periodoFimInput.value) {
formErroEl.textContent = erroPeriodoFim
? `"Razão até": ${erroPeriodoFim}`
: "Informe até quando o razão foi exportado (\"Razão até\").";
periodoFimCampo.focar();
return; return;
} }
clearTimeout(timerNomeEmpresa); clearTimeout(timerNomeEmpresa);
@ -486,14 +587,14 @@ document.addEventListener("DOMContentLoaded", async () => {
const cancelarBtn = document.getElementById("conc-form-cancelar-btn"); const cancelarBtn = document.getElementById("conc-form-cancelar-btn");
processarBtn.disabled = true; processarBtn.disabled = true;
cancelarBtn.disabled = true; cancelarBtn.disabled = true;
processarBtn.textContent = "Processando..."; processarBtn.textContent = "Processando…";
const progresso = pidConcIniciarProgresso(document.getElementById("conc-form-progress")); const progresso = pidConcIniciarProgresso(document.getElementById("conc-form-progress"));
try { try {
const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados }); const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados });
await progresso.completar(); await progresso.completar();
formEl.hidden = true; formEl.hidden = true;
await carregarHistorico(); await carregarHistorico();
abrirDetalhe(criada.id); abrirDetalhe(criada.id, { empilhar: true });
} catch (erro) { } catch (erro) {
progresso.parar(); progresso.parar();
formErroEl.textContent = erro.message; formErroEl.textContent = erro.message;
@ -533,32 +634,18 @@ document.addEventListener("DOMContentLoaded", async () => {
<td class="conc-num"> <td class="conc-num">
<span class="conc-lista-acoes"> <span class="conc-lista-acoes">
<button type="button" class="btn-outline conc-btn-sm" data-atualizar>Atualizar</button> <button type="button" class="btn-outline conc-btn-sm" data-atualizar>Atualizar</button>
<button type="button" class="btn-danger-outline conc-btn-sm" data-excluir>Excluir</button>
</span> </span>
</td>`; </td>`;
tr.addEventListener("click", (evento) => { tr.addEventListener("click", (evento) => {
if (evento.target.closest("[data-excluir], [data-atualizar]")) return; if (evento.target.closest("[data-atualizar]")) return;
abrirDetalhe(item.id); abrirDetalhe(item.id, { empilhar: true });
}); });
tr.querySelector("[data-atualizar]").addEventListener("click", () => abrirAtualizar(item)); tr.querySelector("[data-atualizar]").addEventListener("click", () => abrirAtualizar(item));
tr.querySelector("[data-excluir]").addEventListener("click", async () => {
const confirmado = await pidConfirm(
`Excluir a conciliação de ${item.codigo_empresa} - ${item.nome_empresa || ""} (data-base ${pidConcData(item.data_base)})? Os vínculos manuais e validações feitos nela também serão excluídos.`,
{ titulo: "Excluir conciliação", textoConfirmar: "Excluir", perigoso: true }
);
if (!confirmado) return;
try {
await pidApiRequest(`/conciliacoes-fornecedores/${item.id}/`, { method: "DELETE" });
await carregarHistorico();
} catch (erro) {
pidAlert(erro.message, { titulo: "Não foi possível excluir" });
}
});
listaBody.appendChild(tr); listaBody.appendChild(tr);
}); });
} }
listaBusca.addEventListener("input", renderHistorico); listaBusca.addEventListener("input", pidConcAtrasado(renderHistorico));
async function carregarHistorico() { async function carregarHistorico() {
try { try {
@ -579,6 +666,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const atualizarArquivoInput = document.getElementById("conc-atualizar-arquivo"); const atualizarArquivoInput = document.getElementById("conc-atualizar-arquivo");
const atualizarArquivoNome = document.getElementById("conc-atualizar-arquivo-nome"); const atualizarArquivoNome = document.getElementById("conc-atualizar-arquivo-nome");
const atualizarPeriodoFim = document.getElementById("conc-atualizar-periodo-fim"); const atualizarPeriodoFim = document.getElementById("conc-atualizar-periodo-fim");
const atualizarPeriodoFimCampo = pidConcCampoData(atualizarPeriodoFim);
const atualizarErro = document.getElementById("conc-atualizar-erro"); const atualizarErro = document.getElementById("conc-atualizar-erro");
const atualizarBtn = document.getElementById("conc-atualizar-processar-btn"); const atualizarBtn = document.getElementById("conc-atualizar-processar-btn");
let atualizarAlvo = null; let atualizarAlvo = null;
@ -605,10 +693,11 @@ document.addEventListener("DOMContentLoaded", async () => {
// O razão novo não pode terminar antes do atual. // O razão novo não pode terminar antes do atual.
atualizarPeriodoFim.min = item.periodo_fim || ""; atualizarPeriodoFim.min = item.periodo_fim || "";
atualizarPeriodoFim.max = pidConcIsoLocal(new Date()); atualizarPeriodoFim.max = pidConcIsoLocal(new Date());
atualizarPeriodoFim.value = atualizarPeriodoFimCampo.definir(
periodoFimInicial && (!item.periodo_fim || periodoFimInicial >= item.periodo_fim) periodoFimInicial && (!item.periodo_fim || periodoFimInicial >= item.periodo_fim)
? periodoFimInicial ? periodoFimInicial
: pidConcRazaoAtePadrao(item.periodo_fim); : pidConcRazaoAtePadrao(item.periodo_fim)
);
atualizarErro.textContent = ""; atualizarErro.textContent = "";
atualizarModal.hidden = false; atualizarModal.hidden = false;
} }
@ -658,8 +747,12 @@ document.addEventListener("DOMContentLoaded", async () => {
atualizarErro.textContent = "Selecione o arquivo do razão atualizado."; atualizarErro.textContent = "Selecione o arquivo do razão atualizado.";
return; return;
} }
if (!atualizarPeriodoFim.value) { const erroPeriodoFim = atualizarPeriodoFimCampo.validar();
atualizarErro.textContent = "Informe até quando o razão foi exportado (\"Razão até\")."; if (erroPeriodoFim || !atualizarPeriodoFim.value) {
atualizarErro.textContent = erroPeriodoFim
? `"Razão até": ${erroPeriodoFim}`
: "Informe até quando o razão foi exportado (\"Razão até\").";
atualizarPeriodoFimCampo.focar();
return; return;
} }
const alvo = atualizarAlvo; const alvo = atualizarAlvo;
@ -669,7 +762,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const cancelarBtn = document.getElementById("conc-atualizar-cancelar-btn"); const cancelarBtn = document.getElementById("conc-atualizar-cancelar-btn");
atualizarBtn.disabled = true; atualizarBtn.disabled = true;
cancelarBtn.disabled = true; cancelarBtn.disabled = true;
atualizarBtn.textContent = "Atualizando..."; atualizarBtn.textContent = "Atualizando…";
const progresso = pidConcIniciarProgresso(document.getElementById("conc-atualizar-progress")); const progresso = pidConcIniciarProgresso(document.getElementById("conc-atualizar-progress"));
let resposta; let resposta;
try { try {
@ -689,7 +782,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const texto = resumoAtualizacaoTexto({ ...resposta.atualizacao, periodo_novo: periodoTexto(resposta.conciliacao) }); const texto = resumoAtualizacaoTexto({ ...resposta.atualizacao, periodo_novo: periodoTexto(resposta.conciliacao) });
atualizarAlvo = null; atualizarAlvo = null;
if (!detailView.hidden && detalhe && detalhe.id === alvo.id) { if (!detailView.hidden && detalhe && detalhe.id === alvo.id) {
await abrirDetalhe(alvo.id); const atual = pidConcLeHash();
await abrirDetalhe(alvo.id, { params: atual && atual.params });
} else { } else {
await carregarHistorico(); await carregarHistorico();
} }
@ -719,34 +813,146 @@ document.addEventListener("DOMContentLoaded", async () => {
const abertas = new Set(); const abertas = new Set();
// estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados // estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados
const estadoConta = new Map(); const estadoConta = new Map();
// Observações digitadas e ainda não salvas, por conta (pedido do usuário:
// o texto não pode sumir quando a conta é redesenhada). Sobrevivem ao
// recolher/expandir e à troca de filtro; ao sair da conciliação com
// rascunho pendente, a tela pergunta antes.
const rascunhos = new Map(); // observação da conciliação
const rascunhosVenc = new Map(); // observação dos títulos antigos
function estadoDe(contaId) { function estadoDe(contaId) {
if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false }); if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false });
return estadoConta.get(contaId); return estadoConta.get(contaId);
} }
document.getElementById("conc-voltar-btn").addEventListener("click", () => { function temRascunhoPendente() {
if (!detalhe) return false;
return detalhe.contas.some(
(c) =>
(rascunhos.has(c.id) && rascunhos.get(c.id) !== (c.observacao || "")) ||
(rascunhosVenc.has(c.id) && rascunhosVenc.get(c.id) !== (c.vencidos_observacao || ""))
);
}
/** Pergunta antes de descartar observações não salvas; `true` = pode sair. */
async function podeSairDoDetalhe() {
if (!temRascunhoPendente()) return true;
const sair = await pidConfirm(
"Há observações digitadas e não salvas nesta conciliação. Sair sem salvar as alterações?",
{ titulo: "Sair sem salvar", textoConfirmar: "Sair sem salvar", perigoso: true }
);
if (sair) {
rascunhos.clear();
rascunhosVenc.clear();
}
return sair;
}
window.addEventListener("beforeunload", (evento) => {
if (temRascunhoPendente()) evento.preventDefault();
});
// ---- Estado na URL ----
// `#conciliacao-<id>` seguido dos filtros, da aba e da ordenação
// (`?aba=vencidos&status=divergente&ordem=-saldo_final`), para recarregar
// a página voltar ao mesmo ponto. Abrir uma conciliação empilha uma
// entrada no histórico do navegador, então o Voltar dele volta à lista;
// trocar filtro só substitui a entrada atual.
function hashDetalhe() {
const params = new URLSearchParams();
if (abaAtual === "vencidos") params.set("aba", "vencidos");
if (filtroStatus) params.set("status", filtroStatus);
if (filtroAlerta) params.set("alerta", filtroAlerta);
if (mostrarValidadasInput.checked) params.set("validadas", "1");
if (sortKey !== SORT_PADRAO || sortDir !== 1) params.set("ordem", `${sortDir === -1 ? "-" : ""}${sortKey}`);
if (vencFiltro !== "pendentes") params.set("vfiltro", vencFiltro || "todos");
if (vencSortKey !== "total_vencido" || vencSortDir !== -1) params.set("vordem", `${vencSortDir === -1 ? "-" : ""}${vencSortKey}`);
const consulta = params.toString();
return `#conciliacao-${detalhe.id}${consulta ? `?${consulta}` : ""}`;
}
function atualizarUrl() {
if (!detalhe || detailView.hidden) return;
const hash = hashDetalhe();
if (window.location.hash !== hash) history.replaceState(history.state, "", hash);
}
/** Aplica os parâmetros lidos da URL (valores desconhecidos são ignorados). */
function aplicarParametros(params) {
if (!params) return;
const status = params.get("status") || "";
if (status === "validada" || PID_CONC_STATUS[status]) filtroStatus = status;
const alerta = params.get("alerta") || "";
if (PID_CONC_ROTULO_ALERTA[alerta]) filtroAlerta = alerta;
mostrarValidadasInput.checked = params.get("validadas") === "1";
const ordem = params.get("ordem") || "";
if (sortValor[ordem.replace(/^-/, "")]) {
sortKey = ordem.replace(/^-/, "");
sortDir = ordem.startsWith("-") ? -1 : 1;
}
const vfiltro = params.get("vfiltro");
if (vfiltro === "validados" || vfiltro === "todos") vencFiltro = vfiltro === "todos" ? "" : vfiltro;
const vordem = params.get("vordem") || "";
if (vencSortValor[vordem.replace(/^-/, "")]) {
vencSortKey = vordem.replace(/^-/, "");
vencSortDir = vordem.startsWith("-") ? -1 : 1;
}
}
document.getElementById("conc-voltar-btn").addEventListener("click", async () => {
if (!(await podeSairDoDetalhe())) return;
// Entrou pela lista: volta a entrada do histórico (o `popstate` mostra a
// lista). Entrou direto pela URL: não há para onde voltar, então troca.
if (history.state && history.state.concDetalhe) {
history.back();
} else {
history.replaceState(null, "", window.location.pathname); history.replaceState(null, "", window.location.pathname);
mostrarLista(); mostrarLista();
}
});
window.addEventListener("popstate", async () => {
const alvo = pidConcLeHash();
if (alvo && detalhe && alvo.id === detalhe.id && !detailView.hidden) return;
if (!detailView.hidden && !(await podeSairDoDetalhe())) {
// cancelou: devolve a entrada da conciliação aberta
history.pushState({ concDetalhe: true }, "", hashDetalhe());
return;
}
if (alvo) abrirDetalhe(alvo.id, { params: alvo.params });
else mostrarLista();
}); });
function mostrarLista() { function mostrarLista() {
detalhe = null; detalhe = null;
rascunhos.clear();
rascunhosVenc.clear();
detailView.hidden = true; detailView.hidden = true;
listView.hidden = false; listView.hidden = false;
carregarHistorico(); carregarHistorico();
} }
async function abrirDetalhe(id) { /** `empilhar`: abrir a partir da lista (cria entrada no histórico do
* navegador). `params`: filtros/aba/ordem lidos da URL. Reabrir a mesma
* conciliação (depois de "Atualizar razão") mantém os rascunhos. */
async function abrirDetalhe(id, { empilhar = false, params = null } = {}) {
const mesma = detalhe && detalhe.id === id;
listView.hidden = true; listView.hidden = true;
detailView.hidden = false; detailView.hidden = false;
cardsEl.innerHTML = ""; cardsEl.innerHTML = "";
contasBody.innerHTML = '<tr><td colspan="8" class="conc-empty">Carregando...</td></tr>'; contasBody.innerHTML = '<tr><td colspan="8" class="conc-empty">Carregando…</td></tr>';
history.replaceState(null, "", `#conciliacao-${id}`); if (empilhar) history.pushState({ concDetalhe: true }, "", `#conciliacao-${id}`);
else if (!pidConcLeHash() || pidConcLeHash().id !== id) history.replaceState(history.state, "", `#conciliacao-${id}`);
if (!mesma) {
rascunhos.clear();
rascunhosVenc.clear();
}
abertas.clear(); abertas.clear();
estadoConta.clear(); estadoConta.clear();
vencAbertas.clear(); vencAbertas.clear();
vencFiltro = "pendentes"; vencFiltro = "pendentes";
vencSortKey = "total_vencido";
vencSortDir = -1;
vencBusca.value = ""; vencBusca.value = "";
detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar
mostrarAba("conciliacao"); mostrarAba("conciliacao");
@ -756,6 +962,7 @@ document.addEventListener("DOMContentLoaded", async () => {
mostrarValidadasInput.checked = false; mostrarValidadasInput.checked = false;
sortKey = SORT_PADRAO; sortKey = SORT_PADRAO;
sortDir = 1; sortDir = 1;
aplicarParametros(params);
atualizarSortHeaders(); atualizarSortHeaders();
try { try {
detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`); detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`);
@ -764,8 +971,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
renderCabecalho(); renderCabecalho();
renderCards(); mostrarAba(params && params.get("aba") === "vencidos" ? "vencidos" : "conciliacao");
renderContas();
} }
function renderCabecalho() { function renderCabecalho() {
@ -887,7 +1093,7 @@ document.addEventListener("DOMContentLoaded", async () => {
renderContas(); renderContas();
}); });
}); });
contasBusca.addEventListener("input", renderContas); contasBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderContas()));
mostrarValidadasInput.addEventListener("change", renderContas); mostrarValidadasInput.addEventListener("change", renderContas);
const sortValor = { const sortValor = {
@ -965,6 +1171,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const fragmento = document.createDocumentFragment(); const fragmento = document.createDocumentFragment();
lista.forEach((conta) => montarLinhasConta(conta).forEach((tr) => fragmento.appendChild(tr))); lista.forEach((conta) => montarLinhasConta(conta).forEach((tr) => fragmento.appendChild(tr)));
contasBody.appendChild(fragmento); contasBody.appendChild(fragmento);
atualizarUrl();
} }
function resumoAlertas(conta) { function resumoAlertas(conta) {
@ -989,7 +1196,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<td class="conc-num conc-valor${saldoFinal === 0 ? " conc-valor--zero" : saldoFinal < 0 ? " conc-valor--devedor" : ""}">${pidConcSaldo(conta.saldo_final)}</td> <td class="conc-num conc-valor${saldoFinal === 0 ? " conc-valor--zero" : saldoFinal < 0 ? " conc-valor--devedor" : ""}">${pidConcSaldo(conta.saldo_final)}</td>
<td class="conc-num conc-valor${vencido ? " conc-valor--vencido" : " conc-valor--zero"}">${vencido ? pidConcMoeda(vencido) : "—"}</td> <td class="conc-num conc-valor${vencido ? " conc-valor--vencido" : " conc-valor--zero"}">${vencido ? pidConcMoeda(vencido) : "—"}</td>
<td>${pidConcStatusHtml(conta.status)}</td> <td>${pidConcStatusHtml(conta.status)}</td>
<td class="conc-validado-col"><input type="checkbox" class="conc-validado-check" data-validado-check ${conta.validada ? "checked" : ""} aria-label="Confirmar que a conta foi validada" title="${pidConcEscape(pidConcTextoValidacao(conta) || "Marcar como validada")}" /></td>`; <td class="conc-validado-col"><input type="checkbox" class="conc-validado-check" data-validado-check ${conta.validada ? "checked" : ""} aria-label="Confirmar que a conta foi validada" data-conc-tooltip="${pidConcEscapeAttr(pidConcTextoValidacao(conta) || "Marcar como validada")}" data-conc-tooltip-sempre /></td>`;
const check = tr.querySelector("[data-validado-check]"); const check = tr.querySelector("[data-validado-check]");
// o clique no checkbox não expande/recolhe a linha // o clique no checkbox não expande/recolhe a linha
check.addEventListener("click", (evento) => evento.stopPropagation()); check.addEventListener("click", (evento) => evento.stopPropagation());
@ -1139,7 +1346,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tabela.className = "conc-subtabela conc-subtabela--pendentes"; tabela.className = "conc-subtabela conc-subtabela--pendentes";
tabela.classList.toggle("is-ocultando-conciliados", Boolean(estado.ocultarConciliados)); tabela.classList.toggle("is-ocultando-conciliados", Boolean(estado.ocultarConciliados));
tabela.innerHTML = ` tabela.innerHTML = `
<thead><tr><th class="conc-subtabela__check"><input type="checkbox" data-lanc-todos aria-label="Marcar ou desmarcar todos os não conciliados" title="Marcar todos" /></th><th>Data</th><th>Seq.</th><th>Histórico</th><th>Natureza</th><th class="conc-num">Valor</th><th class="conc-num">Saldo</th><th class="conc-num">Em aberto</th><th>Situação</th></tr></thead> <thead><tr><th class="conc-subtabela__check"><input type="checkbox" data-lanc-todos aria-label="Marcar ou desmarcar todos os não conciliados" data-conc-tooltip="Marcar todos" data-conc-tooltip-sempre /></th><th>Data</th><th>Seq.</th><th>Histórico</th><th>Natureza</th><th class="conc-num">Valor</th><th class="conc-num">Saldo</th><th class="conc-num">Em aberto</th><th>Situação</th></tr></thead>
<tbody>${linhasRazaoHtml()}</tbody>`; <tbody>${linhasRazaoHtml()}</tbody>`;
secaoPend.appendChild(tabela); secaoPend.appendChild(tabela);
} else { } else {
@ -1348,13 +1555,23 @@ document.addEventListener("DOMContentLoaded", async () => {
vincularBtn.textContent = "Vincular selecionados"; vincularBtn.textContent = "Vincular selecionados";
vincularBtn.disabled = !temAmbos; vincularBtn.disabled = !temAmbos;
} }
// A dica fica no invólucro: botão desabilitado não recebe o mouse
// (`pointer-events: none` no CSS), e ela só explica o botão desabilitado.
painel.querySelectorAll("[data-adicionar]").forEach((btn) => { painel.querySelectorAll("[data-adicionar]").forEach((btn) => {
btn.disabled = lancamentos.length === 0; btn.disabled = lancamentos.length === 0;
const envolto = btn.closest("[data-adicionar-dica]");
if (btn.disabled) {
envolto.dataset.concTooltip = "Marque lançamentos na lista de não conciliados";
envolto.dataset.concTooltipSempre = "";
} else {
delete envolto.dataset.concTooltip;
delete envolto.dataset.concTooltipSempre;
}
}); });
if (todosCheck) { if (todosCheck) {
todosCheck.checked = lancamentos.length > 0 && lancamentos.length === pendentes.length; todosCheck.checked = lancamentos.length > 0 && lancamentos.length === pendentes.length;
todosCheck.indeterminate = lancamentos.length > 0 && lancamentos.length < pendentes.length; todosCheck.indeterminate = lancamentos.length > 0 && lancamentos.length < pendentes.length;
todosCheck.title = lancamentos.length ? "Desmarcar todos" : "Marcar todos"; todosCheck.dataset.concTooltip = lancamentos.length ? "Desmarcar todos" : "Marcar todos";
} }
resumoSelecaoEl.innerHTML = selecionados.length resumoSelecaoEl.innerHTML = selecionados.length
? `Créditos <strong>${pidConcMoeda(creditos)}</strong> · Débitos <strong>${pidConcMoeda(debitos)}</strong> · Diferença <strong>${pidConcMoeda(creditos - debitos)}</strong>` ? `Créditos <strong>${pidConcMoeda(creditos)}</strong> · Débitos <strong>${pidConcMoeda(debitos)}</strong> · Diferença <strong>${pidConcMoeda(creditos - debitos)}</strong>`
@ -1495,7 +1712,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="conc-vinculo__header"> <div class="conc-vinculo__header">
<span><strong>#${numeroVinculo.get(vinculo.id)} ${pidConcEscape(vinculo.tipo_rotulo)}</strong> · ${origem}${detalheDiferenca}</span> <span><strong>#${numeroVinculo.get(vinculo.id)} ${pidConcEscape(vinculo.tipo_rotulo)}</strong> · ${origem}${detalheDiferenca}</span>
<span class="conc-vinculo__acoes"> <span class="conc-vinculo__acoes">
${manual ? '<button type="button" class="btn-outline conc-btn-sm" data-adicionar disabled title="Marque lançamentos na lista de não conciliados">Adicionar selecionados</button>' : ""} ${manual ? '<span class="conc-adicionar-dica" data-adicionar-dica><button type="button" class="btn-outline conc-btn-sm" data-adicionar disabled>Adicionar selecionados</button></span>' : ""}
<button type="button" class="btn-outline conc-btn-sm" data-desfazer>Desfazer vínculo</button> <button type="button" class="btn-outline conc-btn-sm" data-desfazer>Desfazer vínculo</button>
</span> </span>
</div> </div>
@ -1558,7 +1775,7 @@ document.addEventListener("DOMContentLoaded", async () => {
validacao.className = "conc-validacao"; validacao.className = "conc-validacao";
validacao.innerHTML = ` validacao.innerHTML = `
<label class="conc-detail__secao-titulo"><h4>Observação do contador</h4></label> <label class="conc-detail__secao-titulo"><h4>Observação do contador</h4></label>
<textarea data-observacao placeholder="Registre a análise desta conta (ex.: título já pago em outubro, aguardando baixa).">${pidConcEscape(conta.observacao)}</textarea> <textarea data-observacao placeholder="Registre a análise desta conta (ex.: título já pago em outubro, aguardando baixa)…">${pidConcEscape(rascunhos.has(conta.id) ? rascunhos.get(conta.id) : conta.observacao)}</textarea>
<div class="conc-detail__acoes"> <div class="conc-detail__acoes">
<button type="button" class="btn-outline conc-btn-sm" data-salvar-obs>Salvar observação</button> <button type="button" class="btn-outline conc-btn-sm" data-salvar-obs>Salvar observação</button>
<button type="button" class="${conta.validada ? "btn-outline" : "btn-solid"} conc-btn-sm" data-validar>${conta.validada ? "Remover validação" : "Marcar como validada"}</button> <button type="button" class="${conta.validada ? "btn-outline" : "btn-solid"} conc-btn-sm" data-validar>${conta.validada ? "Remover validação" : "Marcar como validada"}</button>
@ -1569,14 +1786,20 @@ document.addEventListener("DOMContentLoaded", async () => {
</button> </button>
</div>`; </div>`;
const observacaoEl = validacao.querySelector("[data-observacao]"); const observacaoEl = validacao.querySelector("[data-observacao]");
observacaoEl.addEventListener("input", () => rascunhos.set(conta.id, observacaoEl.value));
const botoesValidacao = validacao.querySelectorAll("[data-salvar-obs], [data-validar]");
async function salvarValidacao(validada) { async function salvarValidacao(validada) {
// travados até a resposta: um clique duplo mandaria duas vezes
botoesValidacao.forEach((btn) => { btn.disabled = true; });
try { try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, {
method: "POST", method: "POST",
body: { validada, observacao: observacaoEl.value }, body: { validada, observacao: observacaoEl.value },
}); });
rascunhos.delete(conta.id);
aplicarRespostaConta(resposta); aplicarRespostaConta(resposta);
} catch (erro) { } catch (erro) {
botoesValidacao.forEach((btn) => { btn.disabled = false; });
pidAlert(erro.message, { titulo: "Não foi possível salvar" }); pidAlert(erro.message, { titulo: "Não foi possível salvar" });
} }
} }
@ -1691,7 +1914,7 @@ document.addEventListener("DOMContentLoaded", async () => {
renderVencidos(); renderVencidos();
}); });
}); });
vencBusca.addEventListener("input", renderVencidos); vencBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderVencidos()));
function atualizarContagemVencidos() { function atualizarContagemVencidos() {
const todas = contasVencidas(); const todas = contasVencidas();
@ -1727,6 +1950,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const fragmento = document.createDocumentFragment(); const fragmento = document.createDocumentFragment();
lista.forEach((conta) => montarLinhasVencidos(conta).forEach((tr) => fragmento.appendChild(tr))); lista.forEach((conta) => montarLinhasVencidos(conta).forEach((tr) => fragmento.appendChild(tr)));
vencBody.appendChild(fragmento); vencBody.appendChild(fragmento);
atualizarUrl();
} }
function montarLinhasVencidos(conta) { function montarLinhasVencidos(conta) {
@ -1799,7 +2023,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</div> </div>
<div class="conc-validacao"> <div class="conc-validacao">
<label class="conc-detail__secao-titulo"><h4>Observação sobre os títulos antigos</h4></label> <label class="conc-detail__secao-titulo"><h4>Observação sobre os títulos antigos</h4></label>
<textarea data-venc-observacao placeholder="Registre a análise (ex.: títulos já pagos, aguardando baixa no Questor).">${pidConcEscape(conta.vencidos_observacao)}</textarea> <textarea data-venc-observacao placeholder="Registre a análise (ex.: títulos já pagos, aguardando baixa no Questor)…">${pidConcEscape(rascunhosVenc.has(conta.id) ? rascunhosVenc.get(conta.id) : conta.vencidos_observacao)}</textarea>
<div class="conc-detail__acoes"> <div class="conc-detail__acoes">
<button type="button" class="btn-outline conc-btn-sm" data-venc-salvar-obs>Salvar observação</button> <button type="button" class="btn-outline conc-btn-sm" data-venc-salvar-obs>Salvar observação</button>
<button type="button" class="${conta.vencidos_validados ? "btn-outline" : "btn-solid"} conc-btn-sm" data-venc-validar>${conta.vencidos_validados ? "Remover validação" : "Marcar como validado"}</button> <button type="button" class="${conta.vencidos_validados ? "btn-outline" : "btn-solid"} conc-btn-sm" data-venc-validar>${conta.vencidos_validados ? "Remover validação" : "Marcar como validado"}</button>
@ -1811,10 +2035,14 @@ document.addEventListener("DOMContentLoaded", async () => {
</div> </div>
</div>`; </div>`;
const observacaoEl = painel.querySelector("[data-venc-observacao]"); const observacaoEl = painel.querySelector("[data-venc-observacao]");
observacaoEl.addEventListener("input", () => rascunhosVenc.set(conta.id, observacaoEl.value));
const botoesValidacao = painel.querySelectorAll("[data-venc-salvar-obs], [data-venc-validar]");
const salvar = async (validada) => { const salvar = async (validada) => {
botoesValidacao.forEach((btn) => { btn.disabled = true; });
try { try {
await salvarVencidos(conta, validada, observacaoEl.value); await salvarVencidos(conta, validada, observacaoEl.value);
} catch (erro) { } catch (erro) {
botoesValidacao.forEach((btn) => { btn.disabled = false; });
pidAlert(erro.message, { titulo: "Não foi possível salvar" }); pidAlert(erro.message, { titulo: "Não foi possível salvar" });
} }
}; };
@ -1858,6 +2086,7 @@ document.addEventListener("DOMContentLoaded", async () => {
method: "POST", method: "POST",
body: corpo, body: corpo,
}); });
if (observacao !== undefined) rascunhosVenc.delete(conta.id);
const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id); const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id);
const eraValidada = indice >= 0 && detalhe.contas[indice].vencidos_validados; const eraValidada = indice >= 0 && detalhe.contas[indice].vencidos_validados;
if (indice >= 0) detalhe.contas[indice] = resposta.conta; if (indice >= 0) detalhe.contas[indice] = resposta.conta;
@ -1872,9 +2101,9 @@ document.addEventListener("DOMContentLoaded", async () => {
// ====================================================================== // ======================================================================
// Início // Início
// ====================================================================== // ======================================================================
const hash = window.location.hash.match(/^#conciliacao-(\d+)$/); const hash = pidConcLeHash();
if (hash) { if (hash) {
abrirDetalhe(Number(hash[1])); abrirDetalhe(hash.id, { params: hash.params });
} else { } else {
carregarHistorico(); carregarHistorico();
} }

View File

@ -26,10 +26,38 @@ document.addEventListener("DOMContentLoaded", async () => {
return isNaN(numero) ? 0 : numero; return isNaN(numero) ? 0 : numero;
} }
/** Número no formato brasileiro ("1.234,56", "7,5", "1234") ou `null` se
* o texto não for um número. Usado nas tabelas fiscais, onde um valor
* inválido virava 0 sem aviso e era gravado para todos (revisão de
* 30/09/2026). */
function parseValorBrEstrito(texto) {
const limpo = String(texto || "").trim();
if (!/^-?(\d{1,3}(\.\d{3})+|\d+)(,\d+)?$/.test(limpo)) return null;
return parseFloat(limpo.replace(/\./g, "").replace(",", "."));
}
// Sem separador de milhar: usada pra montar o payload (percentual
// convertido em valor), que o servidor lê como número simples.
function formataValorBr(numero) { function formataValorBr(numero) {
return numero.toFixed(2).replace(".", ","); return numero.toFixed(2).replace(".", ",");
} }
// Com separador de milhar ("7.350,00"), só pra preencher os inputs das
// tabelas fiscais: parseValorBrEstrito aceita as duas formas
// ("1.234,56" e "1234,56") ao salvar.
const PID_CC_VALOR_EXIBICAO = new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function formataValorBrMilhar(numero) {
return PID_CC_VALOR_EXIBICAO.format(Number(numero) || 0);
}
// Coeficiente B da Lei 15.270/2025 tem muitas casas e vem do banco com
// ponto ("0.133145"): exibido com vírgula, sem arredondar e sem milhar.
// O salvar aceita vírgula ou ponto.
function formataCoeficienteBr(numero) {
if (numero === null || numero === undefined || numero === "") return "";
return String(numero).replace(".", ",");
}
/** /**
* Converte um texto que pode ser um valor em reais (ex.: "3.400,00") ou um * Converte um texto que pode ser um valor em reais (ex.: "3.400,00") ou um
* percentual do salário contratual (ex.: "40%") pro valor em reais final — * percentual do salário contratual (ex.: "40%") pro valor em reais final —
@ -132,7 +160,7 @@ document.addEventListener("DOMContentLoaded", async () => {
* importacao-plano-saude.js), mas abre o resultado numa aba nova em vez de * importacao-plano-saude.js), mas abre o resultado numa aba nova em vez de
* forçar download — a ideia é já deixar o PDF pronto pra visualizar/imprimir/enviar. * forçar download — a ideia é já deixar o PDF pronto pra visualizar/imprimir/enviar.
*/ */
async function geraPdfSimulacao(payload) { async function geraPdfSimulacao(payload, janela) {
await pidEnsureCsrfCookie(); await pidEnsureCsrfCookie();
const response = await fetch("/api/simulacao-custo-contratacao/gerar/", { const response = await fetch("/api/simulacao-custo-contratacao/gerar/", {
method: "POST", method: "POST",
@ -156,7 +184,56 @@ document.addEventListener("DOMContentLoaded", async () => {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
window.open(url, "_blank"); // A aba foi aberta no clique (antes de qualquer await), senão o
// bloqueador de pop-up costuma barrar; sem ela, tenta abrir agora.
if (janela && !janela.closed) janela.location.href = url;
else window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
/** Linha de item com valor preenchido e sem nome: antes era descartada do
* cálculo sem aviso. Devolve o nome da seção com problema, ou "". */
function secaoComItemSemNome() {
const secoes = [
[remuneracaoOutrosBody, "Outros itens de remuneração"],
[beneficiosOutrosBody, "Outros benefícios"],
[descontosOutrosBody, "Outros descontos"],
];
for (const [tbody, rotulo] of secoes) {
const semNome = Array.from(tbody.children).find(
(tr) => !tr.querySelector('[data-campo="nome"]').value.trim() && tr.querySelector('[data-campo="valor"]').value.trim()
);
if (semNome) {
semNome.querySelector('[data-campo="nome"]').focus();
return rotulo;
}
}
return "";
}
// Leva o cursor ao campo apontado por uma mensagem de validação (a
// mensagem fica no rodapé do formulário, longe do campo).
function focarCampo(id) {
const campo = document.getElementById(id);
if (!campo) return;
campo.scrollIntoView({ block: "center", behavior: "smooth" });
campo.focus({ preventScroll: true });
}
// Há algo preenchido no formulário da simulação (campo com valor diferente
// do original do HTML, ou linha incluída em "Outros itens"/"Outros
// benefícios"/"Outros descontos")? Só nesse caso "Limpar" pergunta antes.
function formularioTemConteudo() {
const campoAlterado = Array.from(formEl.querySelectorAll("input, select, textarea")).some((el) => {
if (el.type === "checkbox" || el.type === "radio") return el.checked !== el.defaultChecked;
return el.value.trim() !== (el.defaultValue || "").trim();
});
return (
campoAlterado ||
remuneracaoOutrosBody.children.length > 0 ||
beneficiosOutrosBody.children.length > 0 ||
descontosOutrosBody.children.length > 0
);
} }
formEl.addEventListener("submit", async (event) => { formEl.addEventListener("submit", async (event) => {
@ -166,18 +243,27 @@ document.addEventListener("DOMContentLoaded", async () => {
const payload = montaPayload(); const payload = montaPayload();
if (!payload.salario_contratual) { if (!payload.salario_contratual) {
errorEl.textContent = "Informe o salário contratual."; errorEl.textContent = "Informe o salário contratual.";
focarCampo("cc-salario");
return; return;
} }
if (!payload.percentual_inss_empregador || !payload.percentual_fgts) { if (!payload.percentual_inss_empregador || !payload.percentual_fgts) {
errorEl.textContent = "Informe o % de INSS Empregador e o % de FGTS."; errorEl.textContent = "Informe o % de INSS Empregador e o % de FGTS.";
focarCampo(payload.percentual_inss_empregador ? "cc-perc-fgts" : "cc-perc-inss");
return;
}
const secaoSemNome = secaoComItemSemNome();
if (secaoSemNome) {
errorEl.textContent = `Em "${secaoSemNome}", há um item com valor e sem nome. Informe o nome ou remova a linha.`;
return; return;
} }
const janela = window.open("", "_blank");
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = "Gerando..."; submitBtn.textContent = "Gerando…";
try { try {
await geraPdfSimulacao(payload); await geraPdfSimulacao(payload, janela);
} catch (e) { } catch (e) {
if (janela) janela.close();
errorEl.textContent = e.message; errorEl.textContent = e.message;
} finally { } finally {
submitBtn.disabled = false; submitBtn.disabled = false;
@ -186,7 +272,17 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
if (limparBtn) { if (limparBtn) {
limparBtn.addEventListener("click", () => { limparBtn.addEventListener("click", async () => {
if (
formularioTemConteudo() &&
!(await pidConfirm("Limpar todos os campos da simulação? O que foi preenchido será perdido.", {
titulo: "Limpar simulação",
textoConfirmar: "Limpar",
perigoso: true,
}))
) {
return;
}
formEl.reset(); formEl.reset();
remuneracaoOutrosBody.innerHTML = ""; remuneracaoOutrosBody.innerHTML = "";
beneficiosOutrosBody.innerHTML = ""; beneficiosOutrosBody.innerHTML = "";
@ -214,13 +310,13 @@ document.addEventListener("DOMContentLoaded", async () => {
function criarLinhaFaixa(faixa) { function criarLinhaFaixa(faixa) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const limite = formataValorBr(faixa.limite_superior); const limite = formataValorBrMilhar(faixa.limite_superior);
const aliquota = formataValorBr(faixa.aliquota * 100); const aliquota = formataValorBrMilhar(faixa.aliquota * 100);
const deduzir = formataValorBr(faixa.deduzir); const deduzir = formataValorBrMilhar(faixa.deduzir);
tr.innerHTML = tr.innerHTML =
'<td><input type="text" class="cc-tabela-fiscal__input" data-campo="limite_superior" autocomplete="off" /></td>' + '<td><input type="text" class="cc-tabela-fiscal__input" data-campo="limite_superior" inputmode="decimal" autocomplete="off" /></td>' +
'<td><input type="text" class="cc-tabela-fiscal__input" data-campo="aliquota" autocomplete="off" /></td>' + '<td><input type="text" class="cc-tabela-fiscal__input" data-campo="aliquota" inputmode="decimal" autocomplete="off" /></td>' +
'<td><input type="text" class="cc-tabela-fiscal__input" data-campo="deduzir" autocomplete="off" /></td>' + '<td><input type="text" class="cc-tabela-fiscal__input" data-campo="deduzir" inputmode="decimal" autocomplete="off" /></td>' +
'<td><button type="button" class="icon-btn cc-tabela-fiscal__remove" aria-label="Remover faixa" title="Remover faixa">✕</button></td>'; '<td><button type="button" class="icon-btn cc-tabela-fiscal__remove" aria-label="Remover faixa" title="Remover faixa">✕</button></td>';
tr.querySelector('[data-campo="limite_superior"]').value = limite; tr.querySelector('[data-campo="limite_superior"]').value = limite;
tr.querySelector('[data-campo="aliquota"]').value = aliquota; tr.querySelector('[data-campo="aliquota"]').value = aliquota;
@ -234,35 +330,53 @@ document.addEventListener("DOMContentLoaded", async () => {
faixas.forEach((faixa) => tbodyEl.appendChild(criarLinhaFaixa(faixa))); faixas.forEach((faixa) => tbodyEl.appendChild(criarLinhaFaixa(faixa)));
} }
// Campos inválidos da última tentativa de salvar (marcados em vermelho).
let camposInvalidos = [];
/** Lê um campo numérico das tabelas fiscais; inválido entra na lista de
* erros (e fica marcado) em vez de virar 0. */
function lerNumeroFiscal(input) {
const numero = parseValorBrEstrito(input.value);
input.classList.toggle("is-invalido", numero === null);
if (numero === null) camposInvalidos.push(input);
return numero === null ? 0 : numero;
}
function lerFaixasDoTbody(tbodyEl) { function lerFaixasDoTbody(tbodyEl) {
return Array.from(tbodyEl.children).map((tr) => { return Array.from(tbodyEl.children).map((tr) => {
const valores = {}; const valores = {};
tr.querySelectorAll("input[data-campo]").forEach((input) => { tr.querySelectorAll("input[data-campo]").forEach((input) => {
valores[input.dataset.campo] = parseValorBr(input.value); valores[input.dataset.campo] = lerNumeroFiscal(input);
}); });
return { return {
limite_superior: valores.limite_superior || 0, limite_superior: valores.limite_superior,
aliquota: (valores.aliquota || 0) / 100, aliquota: valores.aliquota / 100,
deduzir: valores.deduzir || 0, deduzir: valores.deduzir,
}; };
}); });
} }
// "Salvar tabelas" fica desabilitado até a carga terminar com sucesso:
// salvar com os campos ainda vazios (carga em andamento ou falha) gravaria
// tabelas zeradas pra todos.
async function carregarParametrosFiscais() { async function carregarParametrosFiscais() {
if (painelSalvarBtn) painelSalvarBtn.disabled = true;
try { try {
const parametros = await pidApiRequest("/parametros-fiscais-custo-contratacao/"); const parametros = await pidApiRequest("/parametros-fiscais-custo-contratacao/");
renderFaixas(inssBody, parametros.faixas_inss); renderFaixas(inssBody, parametros.faixas_inss);
renderFaixas(irrfBody, parametros.faixas_irrf); renderFaixas(irrfBody, parametros.faixas_irrf);
document.getElementById("cc-inss-teto").value = formataValorBr(parametros.teto_desconto_inss); document.getElementById("cc-inss-teto").value = formataValorBrMilhar(parametros.teto_desconto_inss);
document.getElementById("cc-irrf-topo-aliquota").value = formataValorBr(parametros.aliquota_irrf_topo * 100); document.getElementById("cc-irrf-topo-aliquota").value = formataValorBrMilhar(parametros.aliquota_irrf_topo * 100);
document.getElementById("cc-irrf-topo-deduzir").value = formataValorBr(parametros.deduzir_irrf_topo); document.getElementById("cc-irrf-topo-deduzir").value = formataValorBrMilhar(parametros.deduzir_irrf_topo);
document.getElementById("cc-desconto-simplificado").value = formataValorBr(parametros.desconto_simplificado_irrf); document.getElementById("cc-desconto-simplificado").value = formataValorBrMilhar(parametros.desconto_simplificado_irrf);
document.getElementById("cc-deducao-dependente").value = formataValorBr(parametros.deducao_por_dependente); document.getElementById("cc-deducao-dependente").value = formataValorBrMilhar(parametros.deducao_por_dependente);
document.getElementById("cc-lei15270-a").value = formataValorBr(parametros.reducao_lei_15270_coeficiente_a); document.getElementById("cc-lei15270-a").value = formataValorBrMilhar(parametros.reducao_lei_15270_coeficiente_a);
document.getElementById("cc-lei15270-b").value = String(parametros.reducao_lei_15270_coeficiente_b); document.getElementById("cc-lei15270-b").value = formataCoeficienteBr(parametros.reducao_lei_15270_coeficiente_b);
document.getElementById("cc-lei15270-limite").value = formataValorBr(parametros.reducao_lei_15270_limite); document.getElementById("cc-lei15270-limite").value = formataValorBrMilhar(parametros.reducao_lei_15270_limite);
if (painelSalvarBtn) painelSalvarBtn.disabled = false;
} catch (e) { } catch (e) {
painelError.textContent = "Não foi possível carregar as tabelas fiscais."; painelError.textContent =
"Não foi possível carregar as tabelas fiscais. Recarregue a página; se persistir, contate a Inovação. O salvar fica desabilitado até as tabelas carregarem.";
} }
} }
@ -284,20 +398,36 @@ document.addEventListener("DOMContentLoaded", async () => {
painelSalvarBtn.addEventListener("click", async () => { painelSalvarBtn.addEventListener("click", async () => {
painelError.textContent = ""; painelError.textContent = "";
painelStatus.hidden = true; painelStatus.hidden = true;
camposInvalidos = [];
const campo = (id) => lerNumeroFiscal(document.getElementById(id));
// Coeficiente B tem muitas casas: exibido com vírgula ("0,133145", ver
// formataCoeficienteBr), aceita ponto ou vírgula decimal, sem
// separador de milhar.
const coeficienteBInput = document.getElementById("cc-lei15270-b");
const coeficienteB = /^\d+([.,]\d+)?$/.test(coeficienteBInput.value.trim())
? parseFloat(coeficienteBInput.value.trim().replace(",", "."))
: null;
coeficienteBInput.classList.toggle("is-invalido", coeficienteB === null);
if (coeficienteB === null) camposInvalidos.push(coeficienteBInput);
const payload = { const payload = {
faixas_inss: lerFaixasDoTbody(inssBody), faixas_inss: lerFaixasDoTbody(inssBody),
teto_desconto_inss: parseValorBr(campoValor("cc-inss-teto")), teto_desconto_inss: campo("cc-inss-teto"),
faixas_irrf: lerFaixasDoTbody(irrfBody), faixas_irrf: lerFaixasDoTbody(irrfBody),
aliquota_irrf_topo: parseValorBr(campoValor("cc-irrf-topo-aliquota")) / 100, aliquota_irrf_topo: campo("cc-irrf-topo-aliquota") / 100,
deduzir_irrf_topo: parseValorBr(campoValor("cc-irrf-topo-deduzir")), deduzir_irrf_topo: campo("cc-irrf-topo-deduzir"),
desconto_simplificado_irrf: parseValorBr(campoValor("cc-desconto-simplificado")), desconto_simplificado_irrf: campo("cc-desconto-simplificado"),
deducao_por_dependente: parseValorBr(campoValor("cc-deducao-dependente")), deducao_por_dependente: campo("cc-deducao-dependente"),
reducao_lei_15270_coeficiente_a: parseValorBr(campoValor("cc-lei15270-a")), reducao_lei_15270_coeficiente_a: campo("cc-lei15270-a"),
reducao_lei_15270_coeficiente_b: parseFloat(campoValor("cc-lei15270-b").replace(",", ".")) || 0, reducao_lei_15270_coeficiente_b: coeficienteB || 0,
reducao_lei_15270_limite: parseValorBr(campoValor("cc-lei15270-limite")), reducao_lei_15270_limite: campo("cc-lei15270-limite"),
}; };
if (camposInvalidos.length) {
painelError.textContent = `${camposInvalidos.length} campo(s) com valor inválido, marcados em vermelho. Use números no formato 1.234,56; nada foi salvo.`;
camposInvalidos[0].focus();
return;
}
painelSalvarBtn.disabled = true; painelSalvarBtn.disabled = true;
painelSalvarBtn.textContent = "Salvando..."; painelSalvarBtn.textContent = "Salvando…";
try { try {
const parametros = await pidApiRequest("/parametros-fiscais-custo-contratacao/", { const parametros = await pidApiRequest("/parametros-fiscais-custo-contratacao/", {
method: "PATCH", method: "PATCH",

File diff suppressed because it is too large Load Diff

View File

@ -91,9 +91,18 @@ document.addEventListener("DOMContentLoaded", async () => {
starButtons.push({ id: app.id, btn: starBtn }); starButtons.push({ id: app.id, btn: starBtn });
starBtn.addEventListener("click", async () => { starBtn.addEventListener("click", async () => {
// Trava a própria estrela: clique duplo mandaria dois POST/DELETE.
if (starBtn.disabled) return;
const isFavorite = starBtn.classList.contains("is-favorite"); const isFavorite = starBtn.classList.contains("is-favorite");
starBtn.disabled = true;
try {
await pidToggleFavorite(app.id, isFavorite); await pidToggleFavorite(app.id, isFavorite);
await refreshAll(); await refreshAll();
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível atualizar os favoritos" });
} finally {
starBtn.disabled = false;
}
}); });
}); });
@ -138,8 +147,12 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
} }
const textoFavoritosVazio = favoritesEmpty ? favoritesEmpty.textContent : "";
async function refreshAll() { async function refreshAll() {
favorites = await pidFetchFavorites(); favorites = await pidFetchFavorites();
// Se a carga inicial falhou, o aviso de erro ocupou este parágrafo: devolve o texto normal.
if (favoritesEmpty) favoritesEmpty.textContent = textoFavoritosVazio;
favIds = favorites.map((f) => f.app_id); favIds = favorites.map((f) => f.app_id);
syncStarButtons(); syncStarButtons();
renderDashboard(); renderDashboard();
@ -158,9 +171,22 @@ document.addEventListener("DOMContentLoaded", async () => {
grid.addEventListener("click", async (event) => { grid.addEventListener("click", async (event) => {
const removeBtn = event.target.closest("[data-fav-remove]"); const removeBtn = event.target.closest("[data-fav-remove]");
if (removeBtn) { if (removeBtn) {
if (removeBtn.disabled) return;
const appId = removeBtn.getAttribute("data-fav-remove"); const appId = removeBtn.getAttribute("data-fav-remove");
const app = apps.find((a) => a.id === appId);
const confirmado = await pidConfirm(
`Remover "${app ? app.label : "esta aplicação"}" dos favoritos?`,
{ titulo: "Remover favorito", textoConfirmar: "Remover" }
);
if (!confirmado) return;
removeBtn.disabled = true;
try {
await pidToggleFavorite(appId, true); await pidToggleFavorite(appId, true);
await refreshAll(); await refreshAll();
} catch (e) {
removeBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível remover o favorito" });
}
return; return;
} }
@ -238,8 +264,18 @@ document.addEventListener("DOMContentLoaded", async () => {
favorites = reordered; favorites = reordered;
favIds = favorites.map((f) => f.app_id); favIds = favorites.map((f) => f.app_id);
renderDashboard(); renderDashboard();
try {
await Promise.all(updates.map(({ favorito, index }) => pidUpdateFavoriteOrdem(favorito.app_id, index))); await Promise.all(updates.map(({ favorito, index }) => pidUpdateFavoriteOrdem(favorito.app_id, index)));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível salvar a nova ordem" });
}
// Recarrega sempre do servidor: se algum PATCH falhou, a tela volta à
// ordem realmente gravada em vez de mostrar uma ordem que não persistiu.
try {
await refreshAll(); await refreshAll();
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível recarregar os favoritos" });
}
}); });
grid.addEventListener("dragend", () => { grid.addEventListener("dragend", () => {
@ -250,5 +286,13 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
try {
await refreshAll(); await refreshAll();
} catch (e) {
// Erro de carga não pode virar "você ainda não tem favoritos".
if (favoritesEmpty) {
favoritesEmpty.textContent = "Não foi possível carregar seus favoritos. Recarregue a página; se persistir, contate a Inovação.";
favoritesEmpty.hidden = false;
}
}
}); });

View File

@ -120,6 +120,10 @@ const PID_IPS_AUDITORIA_ACAO_WIDTH = 160;
const PID_IPS_COL_MIN_WIDTH = 60; const PID_IPS_COL_MIN_WIDTH = 60;
// Mensagem padrão de falha de carga (histórico, regras, operadoras) — nunca
// mostrar uma lista vazia no lugar de um erro de rede/servidor.
const PID_IPS_MSG_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
async function pidFetchImportacoesPlanoSaude() { async function pidFetchImportacoesPlanoSaude() {
return (await pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/`)) || []; return (await pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/`)) || [];
} }
@ -394,6 +398,8 @@ document.addEventListener("DOMContentLoaded", async () => {
// entre si (AND) — ex.: empresa "221" + operadora "3755 - Itamed Saúde" // entre si (AND) — ex.: empresa "221" + operadora "3755 - Itamed Saúde"
// já restringe pras duas condições juntas. // já restringe pras duas condições juntas.
let listFiltros = { codigo_empresa: null, nome_operadora: null, status: null, criado_por: null }; let listFiltros = { codigo_empresa: null, nome_operadora: null, status: null, criado_por: null };
// true quando a última busca do histórico falhou (ver refreshList()).
let listaErroCarga = false;
let selectedIds = new Set(); let selectedIds = new Set();
let currentPageIds = []; let currentPageIds = [];
let colWidthsLinha = {}; let colWidthsLinha = {};
@ -469,8 +475,12 @@ document.addEventListener("DOMContentLoaded", async () => {
*/ */
function criarValidadorArquivo({ input, nameEl, removeBtn, statusEl, tipo, getOperadoraKey }) { function criarValidadorArquivo({ input, nameEl, removeBtn, statusEl, tipo, getOperadoraKey }) {
let estadoAtual = null; let estadoAtual = null;
// Mesmo controle de ordem de criarValidadorArquivoMultiplo(): só a
// resposta da validação mais recente vale (limpar() também invalida).
let tokenAtual = 0;
function limpar() { function limpar() {
tokenAtual += 1;
input.value = ""; input.value = "";
nameEl.textContent = "Nenhum arquivo selecionado"; nameEl.textContent = "Nenhum arquivo selecionado";
removeBtn.hidden = true; removeBtn.hidden = true;
@ -490,16 +500,20 @@ document.addEventListener("DOMContentLoaded", async () => {
removeBtn.hidden = false; removeBtn.hidden = false;
statusEl.hidden = false; statusEl.hidden = false;
statusEl.className = "ips-file-field__status ips-file-field__status--pendente"; statusEl.className = "ips-file-field__status ips-file-field__status--pendente";
statusEl.textContent = "Validando arquivo..."; statusEl.textContent = "Validando arquivo…";
estadoAtual = null; estadoAtual = null;
tokenAtual += 1;
const token = tokenAtual;
try { try {
const operadoraKey = getOperadoraKey ? getOperadoraKey() : null; const operadoraKey = getOperadoraKey ? getOperadoraKey() : null;
const resultado = await pidValidarArquivoPlanoSaude(tipo, arquivo, operadoraKey); const resultado = await pidValidarArquivoPlanoSaude(tipo, arquivo, operadoraKey);
if (token !== tokenAtual) return;
estadoAtual = resultado; estadoAtual = resultado;
statusEl.className = `ips-file-field__status ${resultado.valido ? "ips-file-field__status--ok" : "ips-file-field__status--erro"}`; statusEl.className = `ips-file-field__status ${resultado.valido ? "ips-file-field__status--ok" : "ips-file-field__status--erro"}`;
statusEl.textContent = `${resultado.valido ? "✓" : "✗"} ${resultado.mensagem}`; statusEl.textContent = `${resultado.valido ? "✓" : "✗"} ${resultado.mensagem}`;
} catch (e) { } catch (e) {
if (token !== tokenAtual) return;
estadoAtual = { valido: false, mensagem: e.message }; estadoAtual = { valido: false, mensagem: e.message };
statusEl.className = "ips-file-field__status ips-file-field__status--erro"; statusEl.className = "ips-file-field__status ips-file-field__status--erro";
statusEl.textContent = `✗ ${e.message}`; statusEl.textContent = `✗ ${e.message}`;
@ -553,7 +567,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const statusEl = document.createElement("span"); const statusEl = document.createElement("span");
if (item.resultado === null) { if (item.resultado === null) {
statusEl.className = "ips-arquivo-list__status ips-arquivo-list__status--pendente"; statusEl.className = "ips-arquivo-list__status ips-arquivo-list__status--pendente";
statusEl.textContent = "Validando arquivo..."; statusEl.textContent = "Validando arquivo…";
} else { } else {
statusEl.className = `ips-arquivo-list__status ${item.resultado.valido ? "ips-arquivo-list__status--ok" : "ips-arquivo-list__status--erro"}`; statusEl.className = `ips-arquivo-list__status ${item.resultado.valido ? "ips-arquivo-list__status--ok" : "ips-arquivo-list__status--erro"}`;
statusEl.textContent = `${item.resultado.valido ? "✓" : "✗"} ${item.resultado.mensagem}`; statusEl.textContent = `${item.resultado.valido ? "✓" : "✗"} ${item.resultado.mensagem}`;
@ -579,15 +593,24 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
// Cada validação ganha um token próprio no item: trocar de operadora
// revalida tudo de novo, e a resposta de uma validação anterior (com a
// operadora antiga) pode chegar depois da nova. Só a resposta do token
// mais recente vale; item removido da lista também é ignorado.
async function validarItem(item) { async function validarItem(item) {
const token = (item.token || 0) + 1;
item.token = token;
item.resultado = null; item.resultado = null;
render(); render();
let resultado;
try { try {
const operadoraKey = getOperadoraKey ? getOperadoraKey() : null; const operadoraKey = getOperadoraKey ? getOperadoraKey() : null;
item.resultado = await pidValidarArquivoPlanoSaude(tipo, item.file, operadoraKey); resultado = await pidValidarArquivoPlanoSaude(tipo, item.file, operadoraKey);
} catch (e) { } catch (e) {
item.resultado = { valido: false, mensagem: e.message }; resultado = { valido: false, mensagem: e.message };
} }
if (item.token !== token || !arquivos.includes(item)) return;
item.resultado = resultado;
render(); render();
} }
@ -760,8 +783,11 @@ document.addEventListener("DOMContentLoaded", async () => {
function renderList() { function renderList() {
const filtradas = importacoes.filter(importacaoPassaNosFiltros); const filtradas = importacoes.filter(importacaoPassaNosFiltros);
listEmpty.hidden = filtradas.length !== 0; listEmpty.hidden = filtradas.length !== 0;
listEmpty.textContent = // Falha ao buscar o histórico nunca aparece como "nenhuma importação":
importacoes.length === 0 // a lista pode ter registros que só não chegaram.
listEmpty.textContent = listaErroCarga
? PID_IPS_MSG_ERRO_CARGA
: importacoes.length === 0
? "Nenhuma importação realizada ainda." ? "Nenhuma importação realizada ainda."
: "Nenhuma importação encontrada com esse filtro."; : "Nenhuma importação encontrada com esse filtro.";
@ -821,8 +847,10 @@ document.addEventListener("DOMContentLoaded", async () => {
async function refreshList() { async function refreshList() {
try { try {
importacoes = await pidFetchImportacoesPlanoSaude(); importacoes = await pidFetchImportacoesPlanoSaude();
listaErroCarga = false;
} catch (e) { } catch (e) {
importacoes = []; importacoes = [];
listaErroCarga = true;
} }
const idsValidos = new Set(importacoes.map((imp) => imp.id)); const idsValidos = new Set(importacoes.map((imp) => imp.id));
Array.from(selectedIds).forEach((id) => { Array.from(selectedIds).forEach((id) => {
@ -940,6 +968,16 @@ document.addEventListener("DOMContentLoaded", async () => {
// "150,00" / 150 / 150.5 -> "150,00"/"150,50" — usado pra repopular os // "150,00" / 150 / 150.5 -> "150,00"/"150,50" — usado pra repopular os
// inputs de limite/percentual (texto BR) a partir de uma regra salva, cujo // inputs de limite/percentual (texto BR) a partir de uma regra salva, cujo
// custeio_por_tipo já vem validado/parseado como float pelo backend. // custeio_por_tipo já vem validado/parseado como float pelo backend.
// Só para exibir (totais, Auditoria, textos da regra): com separador de
// milhar. `formatarNumeroBr` continua sem milhar porque preenche inputs e
// o FormData, que o servidor lê como número simples.
const PID_IPS_VALOR_EXIBICAO = new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function formatarValorExibicao(valor) {
if (valor === null || valor === undefined || valor === "") return "";
const numero = Number(valor);
return Number.isNaN(numero) ? String(valor) : PID_IPS_VALOR_EXIBICAO.format(numero);
}
function formatarNumeroBr(valor) { function formatarNumeroBr(valor) {
if (valor === null || valor === undefined || valor === "") return ""; if (valor === null || valor === undefined || valor === "") return "";
const numero = Number(valor); const numero = Number(valor);
@ -986,11 +1024,12 @@ document.addEventListener("DOMContentLoaded", async () => {
// Valida se o custeio dos tipos marcados está completo (titular e // Valida se o custeio dos tipos marcados está completo (titular e
// dependente de cada um) — reaproveitado pelo submit da importação e por // dependente de cada um) — reaproveitado pelo submit da importação e por
// "Salvar regra atual...", já que as duas ações exigem a mesma coisa: // "Salvar regra atual...", já que as duas ações exigem a mesma coisa:
// um custeio_por_tipo completo pros tipos selecionados. // um custeio_por_tipo completo pros tipos selecionados. Devolve
function mensagemErroCusteio(tipos) { // `{ mensagem, campo }` (campo = onde levar o cursor) ou null.
if (!tipos.length) return "Selecione ao menos um tipo de importação."; function erroCusteio(tipos) {
if (!tipos.length) return { mensagem: "Selecione ao menos um tipo de importação.", campo: formTipoMensalidade };
if (formTipoRegraEmpresa.checked && !regraEmpresaSelecionada) { if (formTipoRegraEmpresa.checked && !regraEmpresaSelecionada) {
return "Selecione a regra especial a aplicar."; return { mensagem: "Selecione a regra especial a aplicar.", campo: regraEmpresaSelecionarBtn };
} }
const tiposRegraEmpresa = regraEmpresaTiposAtuais(); const tiposRegraEmpresa = regraEmpresaTiposAtuais();
for (const tipo of tipos) { for (const tipo of tipos) {
@ -999,16 +1038,29 @@ document.addEventListener("DOMContentLoaded", async () => {
const chave = `${tipo}-${pessoa}`; const chave = `${tipo}-${pessoa}`;
const rotulo = `${PID_IPS_TIPO_LABELS[tipo]} (${PID_IPS_PESSOA_LABELS[pessoa]})`; const rotulo = `${PID_IPS_TIPO_LABELS[tipo]} (${PID_IPS_PESSOA_LABELS[pessoa]})`;
const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`); const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`);
if (!escolhido) return `Informe o custeio para ${rotulo}.`; if (!escolhido) {
return {
mensagem: `Informe o custeio para ${rotulo}.`,
campo: document.querySelector(`input[name="ips-custeio-${chave}"]`),
};
}
if (escolhido.value === "especifica") { if (escolhido.value === "especifica") {
const limite = document.getElementById(`ips-limite-${chave}`).value.trim(); const limiteInput = document.getElementById(`ips-limite-${chave}`);
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
const limite = limiteInput.value.trim();
const percentual = document.getElementById(`ips-percentual-${chave}`).value.trim(); const percentual = document.getElementById(`ips-percentual-${chave}`).value.trim();
const limiteDesconto = document.getElementById(`ips-limite-desconto-${chave}`).value.trim(); const limiteDesconto = limiteDescontoInput.value.trim();
if (!limite && !percentual && !limiteDesconto) { if (!limite && !percentual && !limiteDesconto) {
return `Informe o limite de valor, o percentual de custeio ou o limite de desconto do empregado para ${rotulo}.`; return {
mensagem: `Informe o limite de valor, o percentual de custeio ou o limite de desconto do empregado para ${rotulo}.`,
campo: limiteInput,
};
} }
if (limiteDesconto && (limite || percentual)) { if (limiteDesconto && (limite || percentual)) {
return `${rotulo}: o limite de desconto do empregado não pode ser combinado com o limite de valor/percentual da empresa.`; return {
mensagem: `${rotulo}: o limite de desconto do empregado não pode ser combinado com o limite de valor/percentual da empresa.`,
campo: limiteDescontoInput,
};
} }
} }
} }
@ -1016,6 +1068,16 @@ document.addEventListener("DOMContentLoaded", async () => {
return null; return null;
} }
// Leva o cursor ao campo apontado por uma mensagem de validação (quando
// dá pra saber qual) e rola até ele. Campo desabilitado ou escondido não
// recebe foco; nesse caso só rola até ele, se estiver visível.
function focarCampoComErro(campo) {
if (!campo) return;
if (campo.offsetParent === null) return;
campo.scrollIntoView({ block: "center", behavior: "smooth" });
if (!campo.disabled) campo.focus({ preventScroll: true });
}
// Repõe no formulário uma regra salva (RegraCusteioPlanoSaude) — os campos // Repõe no formulário uma regra salva (RegraCusteioPlanoSaude) — os campos
// continuam 100% editáveis depois, é só um preenchimento em massa, não um // continuam 100% editáveis depois, é só um preenchimento em massa, não um
// modo "somente leitura". `regra.custeio_por_tipo` já vem validado do // modo "somente leitura". `regra.custeio_por_tipo` já vem validado do
@ -1129,6 +1191,9 @@ document.addEventListener("DOMContentLoaded", async () => {
*/ */
function criarComboboxTexto({ input, list, clearBtn, toggleBtn, onChange }) { function criarComboboxTexto({ input, list, clearBtn, toggleBtn, onChange }) {
let itens = []; let itens = [];
// Preenchido quando a carga dos itens falhou (ver setItens): a lista
// mostra o erro em vez de "Nenhum resultado encontrado.".
let erroCarga = null;
const comboEl = input ? input.closest(".ips-combo") : null; const comboEl = input ? input.closest(".ips-combo") : null;
function fechar() { function fechar() {
@ -1144,7 +1209,7 @@ document.addEventListener("DOMContentLoaded", async () => {
? lista ? lista
.map((it) => `<div class="ips-combo__item" data-combo-key="${escapeAttr(String(it.key))}">${escapeHtml(it.label)}</div>`) .map((it) => `<div class="ips-combo__item" data-combo-key="${escapeAttr(String(it.key))}">${escapeHtml(it.label)}</div>`)
.join("") .join("")
: `<p class="ips-combo__empty">Nenhum resultado encontrado.</p>`; : `<p class="ips-combo__empty">${escapeHtml(erroCarga || "Nenhum resultado encontrado.")}</p>`;
list.hidden = false; list.hidden = false;
} }
@ -1208,8 +1273,9 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
return { return {
setItens(novos) { setItens(novos, { erro } = {}) {
itens = novos || []; itens = novos || [];
erroCarga = erro || null;
}, },
selecionar, selecionar,
limpar() { limpar() {
@ -1267,13 +1333,13 @@ document.addEventListener("DOMContentLoaded", async () => {
if (entrada.modo === "especifica") { if (entrada.modo === "especifica") {
const partes = []; const partes = [];
if (entrada.limite_valor !== null && entrada.limite_valor !== undefined) { if (entrada.limite_valor !== null && entrada.limite_valor !== undefined) {
partes.push(`limite de R$ ${formatarNumeroBr(entrada.limite_valor)}`); partes.push(`limite de R$ ${formatarValorExibicao(entrada.limite_valor)}`);
} }
if (entrada.percentual !== null && entrada.percentual !== undefined) { if (entrada.percentual !== null && entrada.percentual !== undefined) {
partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`); partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`);
} }
if (entrada.limite_desconto_empregado !== null && entrada.limite_desconto_empregado !== undefined) { if (entrada.limite_desconto_empregado !== null && entrada.limite_desconto_empregado !== undefined) {
partes.push(`desconto do empregado limitado a R$ ${formatarNumeroBr(entrada.limite_desconto_empregado)}`); partes.push(`desconto do empregado limitado a R$ ${formatarValorExibicao(entrada.limite_desconto_empregado)}`);
} }
return `regra específica (${partes.join(", ") || "sem detalhe"})`; return `regra específica (${partes.join(", ") || "sem detalhe"})`;
} }
@ -1368,7 +1434,11 @@ document.addEventListener("DOMContentLoaded", async () => {
// (resumo + observação) precisa ficar visível. // (resumo + observação) precisa ficar visível.
function renderReviewResumoCusteio(importacao) { function renderReviewResumoCusteio(importacao) {
const tipos = importacao.tipos_lancamento || []; const tipos = importacao.tipos_lancamento || [];
const temResumo = tipos.length > 0; // Só conta como "tem resumo" se a página tiver onde mostrá-lo: a
// variante De Paula (importacao-plano-saude-de-paula.html) não tem os
// três elementos do resumo, e sem esta checagem a caixa
// .ips-regra-empresa-observacoes aparecia vazia, só com a borda.
const temResumo = !!reviewResumoTipos && tipos.length > 0;
if (reviewResumoTipos) { if (reviewResumoTipos) {
reviewResumoTipos.textContent = `Tipos cobertos: ${tiposLabel(tipos)}`; reviewResumoTipos.textContent = `Tipos cobertos: ${tiposLabel(tipos)}`;
reviewResumoTipos.hidden = !temResumo; reviewResumoTipos.hidden = !temResumo;
@ -1432,14 +1502,43 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
// Devolve false se a busca falhou — quem abre uma tela a partir daqui
// mostra o erro em vez de comboboxes vazios (ver carregarCadastrosBase()).
async function refreshRegras() { async function refreshRegras() {
try { try {
regras = await pidFetchRegrasCusteioPlanoSaude(); regras = await pidFetchRegrasCusteioPlanoSaude();
return true;
} catch (e) { } catch (e) {
regras = []; regras = [];
return false;
} }
} }
// Operadoras + regras empresa + regras de custeio, usados por "Nova
// Importação" e por "Cadastro de Regras". Devolve false se qualquer uma
// das três buscas falhou; `operadorasErroCarga` fica guardado pro
// combobox de operadora de "+ Nova regra", montado só depois.
let operadorasErroCarga = false;
async function carregarCadastrosBase() {
let ok = true;
try {
operadorasCache = await pidFetchOperadorasPlanoSaude();
operadorasErroCarga = false;
} catch (e) {
operadorasCache = [];
operadorasErroCarga = true;
ok = false;
}
try {
regrasEmpresaCache = await pidFetchRegrasEmpresaPlanoSaude();
} catch (e) {
regrasEmpresaCache = [];
ok = false;
}
if (!(await refreshRegras())) ok = false;
return ok;
}
// Lista do modal "Selecionar regra empresa" — registro fixo em // Lista do modal "Selecionar regra empresa" — registro fixo em
// portal_api.planos_saude.regras_empresa, sem "Excluir"/"Editar" (só o // portal_api.planos_saude.regras_empresa, sem "Excluir"/"Editar" (só o
// desenvolvedor cadastra uma regra nova, direto no código). // desenvolvedor cadastra uma regra nova, direto no código).
@ -1564,6 +1663,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (regracadError) regracadError.textContent = ""; if (regracadError) regracadError.textContent = "";
const empresaLabel = regra.nome_empresa ? `${regra.codigo_empresa} - ${regra.nome_empresa}` : regra.codigo_empresa; const empresaLabel = regra.nome_empresa ? `${regra.codigo_empresa} - ${regra.nome_empresa}` : regra.codigo_empresa;
regracadSetModo(`Editando regra existente: ${empresaLabel} · ${operadoraLabel(regra.operadora)}`, "ips-regracad-modo--editando"); regracadSetModo(`Editando regra existente: ${empresaLabel} · ${operadoraLabel(regra.operadora)}`, "ips-regracad-modo--editando");
regracadTirarRetrato();
} }
// Volta pro estado de navegação (empresa/operadora do topo) a partir do // Volta pro estado de navegação (empresa/operadora do topo) a partir do
@ -1605,12 +1705,14 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
regracadNovoEmpresaNome.hidden = false; regracadNovoEmpresaNome.hidden = false;
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--buscando"; regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--buscando";
regracadNovoEmpresaNome.textContent = "Buscando nome da empresa..."; regracadNovoEmpresaNome.textContent = "Buscando nome da empresa…";
let resultado = null; let resultado = null;
let falhaConsulta = false;
try { try {
resultado = await pidBuscarNomeEmpresaPlanoSaude(codigo); resultado = await pidBuscarNomeEmpresaPlanoSaude(codigo);
} catch (e) { } catch (e) {
resultado = null; resultado = null;
falhaConsulta = true;
} }
// O código pode ter mudado enquanto a busca estava em andamento — // O código pode ter mudado enquanto a busca estava em andamento —
// nesse caso, quem disparou de novo já está atualizando o texto. // nesse caso, quem disparou de novo já está atualizando o texto.
@ -1618,6 +1720,13 @@ document.addEventListener("DOMContentLoaded", async () => {
if (resultado && resultado.nome_empresa) { if (resultado && resultado.nome_empresa) {
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome"; regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome";
regracadNovoEmpresaNome.textContent = resultado.nome_empresa; regracadNovoEmpresaNome.textContent = resultado.nome_empresa;
} else if (falhaConsulta) {
// Falha da própria consulta (rede/servidor) não é "empresa não
// existe". Obs.: o backend devolve nome_empresa null tanto pra código
// inexistente quanto pra Questor fora do ar, então esse segundo caso
// ainda cai em "Empresa não encontrada." abaixo.
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--nao-encontrada";
regracadNovoEmpresaNome.textContent = "Não foi possível consultar o Questor agora.";
} else { } else {
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--nao-encontrada"; regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--nao-encontrada";
regracadNovoEmpresaNome.textContent = "Empresa não encontrada."; regracadNovoEmpresaNome.textContent = "Empresa não encontrada.";
@ -1661,7 +1770,7 @@ document.addEventListener("DOMContentLoaded", async () => {
regracadRegraEmEdicao = null; regracadRegraEmEdicao = null;
regracadNovoOperadoraKey = null; regracadNovoOperadoraKey = null;
if (regracadNovoCodigoEmpresa) regracadNovoCodigoEmpresa.value = regracadEmpresaAtual || ""; if (regracadNovoCodigoEmpresa) regracadNovoCodigoEmpresa.value = regracadEmpresaAtual || "";
regracadNovoOperadoraOps.setItens(operadorasCache); regracadNovoOperadoraOps.setItens(operadorasCache, { erro: operadorasErroCarga ? PID_IPS_MSG_ERRO_CARGA : null });
regracadNovoOperadoraOps.limpar(); regracadNovoOperadoraOps.limpar();
limparCusteioForm(); limparCusteioForm();
if (regracadObservacoes) regracadObservacoes.value = ""; if (regracadObservacoes) regracadObservacoes.value = "";
@ -1679,6 +1788,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (regracadError) regracadError.textContent = ""; if (regracadError) regracadError.textContent = "";
regracadSetModo("Cadastrando regra nova", "ips-regracad-modo--novo"); regracadSetModo("Cadastrando regra nova", "ips-regracad-modo--novo");
regracadAtualizarNovoOperadoraDuplicada(); regracadAtualizarNovoOperadoraDuplicada();
regracadTirarRetrato();
if (regracadEmpresaAtual) { if (regracadEmpresaAtual) {
atualizarNomeEmpresaNovo(); atualizarNomeEmpresaNovo();
} else { } else {
@ -1713,26 +1823,73 @@ document.addEventListener("DOMContentLoaded", async () => {
// tela (ex. "Cadastrando regra nova") de antes de fechar, só voltando // tela (ex. "Cadastrando regra nova") de antes de fechar, só voltando
// ao estado vazio depois que as buscas abaixo terminassem. // ao estado vazio depois que as buscas abaixo terminassem.
regracadMostrarVazio(); regracadMostrarVazio();
try { const ok = await carregarCadastrosBase();
operadorasCache = await pidFetchOperadorasPlanoSaude(); regracadEmpresaOps.setItens(empresasComRegra(), { erro: ok ? null : PID_IPS_MSG_ERRO_CARGA });
} catch (e) {
operadorasCache = [];
}
try {
regrasEmpresaCache = await pidFetchRegrasEmpresaPlanoSaude();
} catch (e) {
regrasEmpresaCache = [];
}
await refreshRegras();
regracadEmpresaOps.setItens(empresasComRegra());
regracadMostrarVazio(); regracadMostrarVazio();
if (!ok && regracadError) regracadError.textContent = PID_IPS_MSG_ERRO_CARGA;
} }
function fecharCadastroRegras() { function fecharCadastroRegras() {
regracadModal.hidden = true; regracadModal.hidden = true;
regracadRetratoAbertura = null;
} }
// Retrato do formulário de Cadastro de Regras, pra "Fechar"/clique fora
// só perguntarem quando houver alteração de verdade (padrão "Sair sem
// salvar"). `null` quando nenhum formulário está aberto (estado vazio,
// só navegando entre empresa/operadora): nada a perder.
let regracadRetratoAbertura = null;
function regracadRetratoAtual() {
if (!regracadFormView || regracadFormView.hidden) return null;
return JSON.stringify({
custeio: coletarCusteioAtual(),
regraEmpresa: formTipoRegraEmpresa && formTipoRegraEmpresa.checked && regraEmpresaSelecionada ? regraEmpresaSelecionada.key : "",
regraEmpresaMarcada: !!(formTipoRegraEmpresa && formTipoRegraEmpresa.checked),
observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "",
novoCodigo: regracadModoNovo && regracadNovoCodigoEmpresa ? regracadNovoCodigoEmpresa.value.trim() : "",
novoOperadora: regracadModoNovo ? regracadNovoOperadoraKey : null,
});
}
function regracadTirarRetrato() {
regracadRetratoAbertura = regracadRetratoAtual();
}
function regracadTemAlteracao() {
const atual = regracadRetratoAtual();
if (atual === null) return false;
return atual !== regracadRetratoAbertura;
}
async function tentarFecharCadastroRegras() {
if (
regracadTemAlteracao() &&
!(await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
}))
) {
return;
}
fecharCadastroRegras();
}
// Trava "Nova Importação" enquanto as buscas de abertura estão em
// andamento: clique duplo não dispara carga dupla, e o botão mostra
// "Carregando…" no lugar do rótulo (restaurado no finally).
let abrindoNovaImportacao = false;
async function openNewForm() { async function openNewForm() {
if (abrindoNovaImportacao) return;
abrindoNovaImportacao = true;
const rotuloNewBtn = newBtn ? newBtn.innerHTML : "";
if (newBtn) {
newBtn.disabled = true;
newBtn.textContent = "Carregando…";
}
try {
validadorPlanilha.limpar(); validadorPlanilha.limpar();
validadorArquivo.limpar(); validadorArquivo.limpar();
formError.textContent = ""; formError.textContent = "";
@ -1747,24 +1904,52 @@ document.addEventListener("DOMContentLoaded", async () => {
impOperadoraOps.limpar(); impOperadoraOps.limpar();
if (impOperadoraSearch) impOperadoraSearch.disabled = true; if (impOperadoraSearch) impOperadoraSearch.disabled = true;
if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = true; if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = true;
try { const ok = await carregarCadastrosBase();
operadorasCache = await pidFetchOperadorasPlanoSaude(); impEmpresaOps.setItens(empresasComRegra(), { erro: ok ? null : PID_IPS_MSG_ERRO_CARGA });
} catch (e) { if (!ok) formError.textContent = PID_IPS_MSG_ERRO_CARGA;
operadorasCache = [];
}
try {
regrasEmpresaCache = await pidFetchRegrasEmpresaPlanoSaude();
} catch (e) {
regrasEmpresaCache = [];
}
await refreshRegras();
impEmpresaOps.setItens(empresasComRegra());
showView("form"); showView("form");
} finally {
abrindoNovaImportacao = false;
if (newBtn) {
newBtn.disabled = false;
newBtn.innerHTML = rotuloNewBtn;
}
}
}
// Há algo na Nova Importação que se perderia ao sair (padrão "Sair sem
// salvar"): o formulário sempre abre vazio (openNewForm()), então
// qualquer arquivo anexado, competência digitada ou empresa escolhida já
// é alteração em relação à abertura.
function novaImportacaoTemAlteracao() {
return (
!validadorArquivo.ehVazio() ||
!!(formPlanilha.files && formPlanilha.files[0]) ||
formCompetencia.value.trim() !== "" ||
impEmpresaValor.value !== ""
);
}
async function sairDaNovaImportacao() {
if (
novaImportacaoTemAlteracao() &&
!(await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
}))
) {
return;
}
showView("list");
} }
function abrirRevisao(importacao) { function abrirRevisao(importacao) {
importacaoAtual = importacao; importacaoAtual = importacao;
activeTab = null; activeTab = null;
// Outra importação: rolagem/altura/foco da anterior não valem aqui.
estadoScrollPaineis = {};
panelsEl.innerHTML = "";
if (reviewEmpresa) { if (reviewEmpresa) {
if (importacao.codigo_empresa) { if (importacao.codigo_empresa) {
reviewEmpresa.textContent = importacao.nome_empresa reviewEmpresa.textContent = importacao.nome_empresa
@ -1970,7 +2155,9 @@ document.addEventListener("DOMContentLoaded", async () => {
// importação com muitas linhas. Como o redimensionamento manual por // importação com muitas linhas. Como o redimensionamento manual por
// arrasto, todo esse estado é só inline/local: `renderPanels()` recria os // arrasto, todo esse estado é só inline/local: `renderPanels()` recria os
// elementos do zero (qualquer edição de célula, adicionar/remover linha // elementos do zero (qualquer edição de célula, adicionar/remover linha
// etc.), então volta a nascer não-expandido. // etc.), mas guarda e repõe altura, rolagem e este estado de expansão
// (capturarEstadoPaineis/restaurarEstadoPaineis); só abrir outra
// importação volta ao padrão.
function wireAutoExpandScroll(root) { function wireAutoExpandScroll(root) {
root.querySelectorAll(".ips-table-scroll").forEach((wrap) => { root.querySelectorAll(".ips-table-scroll").forEach((wrap) => {
wrap.addEventListener("dblclick", (event) => { wrap.addEventListener("dblclick", (event) => {
@ -2053,7 +2240,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (campo === "nome_func") return `<div class="ips-grid-cell" role="cell">Total</div>`; if (campo === "nome_func") return `<div class="ips-grid-cell" role="cell">Total</div>`;
if (campo === "valor_empresa" || campo === "valor") { if (campo === "valor_empresa" || campo === "valor") {
const total = campo === "valor_empresa" ? totalValorEmpresa : totalValor; const total = campo === "valor_empresa" ? totalValorEmpresa : totalValor;
return `<div class="ips-grid-cell" role="cell">${formatarNumeroBr(total)}</div>`; return `<div class="ips-grid-cell ips-grid-cell--valor" role="cell">${formatarValorExibicao(total)}</div>`;
} }
return `<div class="ips-grid-cell" role="cell"></div>`; return `<div class="ips-grid-cell" role="cell"></div>`;
}) })
@ -2067,9 +2254,14 @@ document.addEventListener("DOMContentLoaded", async () => {
const celulasHtml = campos const celulasHtml = campos
.map(([campo]) => { .map(([campo]) => {
const editavel = !concluida && (editavelLivre || PID_IPS_CAMPOS_SEMPRE_EDITAVEIS.includes(campo)); const editavel = !concluida && (editavelLivre || PID_IPS_CAMPOS_SEMPRE_EDITAVEIS.includes(campo));
const ehValor = PID_IPS_CAMPOS_EXPRESSAO_VALOR.has(campo);
const classeCelula = ehValor ? "ips-grid-cell ips-grid-cell--valor" : "ips-grid-cell";
// Sem inputmode="decimal": as células de valor aceitam contas
// ("15,30-15"), e o teclado decimal do celular esconde "-" e "+".
const inputmode = "";
return editavel return editavel
? `<div class="ips-grid-cell" role="cell"><input type="text" class="ips-table-input" data-linha-campo="${campo}" value="${escapeAttr(linha[campo])}" /></div>` ? `<div class="${classeCelula}" role="cell"><input type="text" class="ips-table-input" data-linha-campo="${campo}" value="${escapeAttr(linha[campo])}"${inputmode} autocomplete="off" /></div>`
: `<div class="ips-grid-cell" role="cell"><span class="ips-table-value">${escapeHtml(linha[campo])}</span></div>`; : `<div class="${classeCelula}" role="cell"><span class="ips-table-value">${escapeHtml(linha[campo])}</span></div>`;
}) })
.join(""); .join("");
const removerHtml = concluida const removerHtml = concluida
@ -2150,7 +2342,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="ips-grid-cell" role="cell">${escapeHtml(item.cpf || "—")}</div> <div class="ips-grid-cell" role="cell">${escapeHtml(item.cpf || "—")}</div>
<div class="ips-grid-cell" role="cell">${item.tipo === "T" ? "Titular" : "Dependente"}</div> <div class="ips-grid-cell" role="cell">${item.tipo === "T" ? "Titular" : "Dependente"}</div>
<div class="ips-grid-cell" role="cell">${escapeHtml(PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—")}</div> <div class="ips-grid-cell" role="cell">${escapeHtml(PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—")}</div>
<div class="ips-grid-cell" role="cell">${Number(item.valor).toFixed(2).replace(".", ",")}</div> <div class="ips-grid-cell ips-grid-cell--valor" role="cell">${formatarValorExibicao(item.valor)}</div>
<div class="ips-grid-cell" role="cell"><span class="ips-auditoria-motivo">${escapeHtml(PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo)}</span></div> <div class="ips-grid-cell" role="cell"><span class="ips-auditoria-motivo">${escapeHtml(PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo)}</span></div>
<div class="ips-grid-cell" role="cell">${escapeHtml(item.detalhe || "")}</div> <div class="ips-grid-cell" role="cell">${escapeHtml(item.detalhe || "")}</div>
</div>`; </div>`;
@ -2238,8 +2430,106 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>`; </div>`;
} }
// Estado visual de cada `.ips-table-scroll` (rolagem e altura ajustada à
// mão), por painel (chave de data-ips-tab-panel). renderPanels() refaz o
// innerHTML dos painéis a cada edição de célula, ordenação ou
// redimensionamento de coluna; sem guardar isso antes, a tabela voltava
// pro topo a cada valor salvo (revisão de interface de 2026-09-30). Só é
// atualizado a partir do painel visível (um painel `hidden` sempre lê
// scrollTop 0), então trocar de aba e voltar também mantém a posição.
// Zerado em abrirRevisao(), ao trocar de importação.
let estadoScrollPaineis = {};
function capturarEstadoPaineis() {
panelsEl.querySelectorAll("[data-ips-tab-panel]").forEach((painel) => {
const wrap = painel.querySelector(".ips-table-scroll");
if (!wrap) return;
const chave = painel.getAttribute("data-ips-tab-panel");
const anterior = estadoScrollPaineis[chave] || {};
const visivel = !painel.hidden;
estadoScrollPaineis[chave] = {
scrollTop: visivel ? wrap.scrollTop : anterior.scrollTop || 0,
scrollLeft: visivel ? wrap.scrollLeft : anterior.scrollLeft || 0,
height: wrap.style.height,
maxHeight: wrap.style.maxHeight,
autoExpandido: wrap.dataset.autoExpandido,
alturaAnterior: wrap.dataset.alturaAnterior,
maxAlturaAnterior: wrap.dataset.maxAlturaAnterior,
};
});
// Célula em edição no momento do redesenho: normalmente é a PRÓXIMA
// célula (o "change" da anterior dispara no blur), onde o usuário pode
// já ter começado a digitar enquanto o PATCH/refetch da anterior estava
// em andamento. `ipsSalvando` marca o input cujo próprio "change"
// disparou este ciclo (ex.: Enter sem sair da célula): ali o valor do
// servidor prevalece, não o digitado.
const ativo = document.activeElement;
if (!ativo || !panelsEl.contains(ativo) || !ativo.matches("[data-linha-campo]")) return null;
const row = ativo.closest("[data-linha-id]");
if (!row) return null;
// Alguns navegadores disparam focusout ao remover do DOM o elemento
// focado: sem tirar a marca aqui, o handler de focusout salvaria o
// texto ainda pela metade. O input novo recebe a marca de novo em
// restaurarEstadoPaineis(), se for o caso.
delete ativo.dataset.ipsRestaurado;
return {
linhaId: row.getAttribute("data-linha-id"),
campo: ativo.getAttribute("data-linha-campo"),
value: ativo.value,
digitado: ativo.dataset.ipsSalvando !== "1" && ativo.value !== ativo.defaultValue,
selectionStart: ativo.selectionStart,
selectionEnd: ativo.selectionEnd,
};
}
function restaurarEstadoPaineis(foco) {
panelsEl.querySelectorAll("[data-ips-tab-panel]").forEach((painel) => {
const wrap = painel.querySelector(".ips-table-scroll");
const estado = estadoScrollPaineis[painel.getAttribute("data-ips-tab-panel")];
if (!wrap || !estado) return;
wrap.style.height = estado.height || "";
wrap.style.maxHeight = estado.maxHeight || "";
if (estado.autoExpandido) {
wrap.dataset.autoExpandido = estado.autoExpandido;
wrap.dataset.alturaAnterior = estado.alturaAnterior || "";
wrap.dataset.maxAlturaAnterior = estado.maxAlturaAnterior || "";
}
});
if (foco) {
const row = panelsEl.querySelector(`[data-linha-id="${foco.linhaId}"]`);
const input = row && row.querySelector(`[data-linha-campo="${foco.campo}"]`);
if (input) {
if (foco.digitado) {
input.value = foco.value;
// Valor reposto por código não conta como edição do usuário pro
// navegador (o "change" não dispararia no blur) — o focusout em
// panelsEl confere esta marca e salva o que foi digitado.
input.dataset.ipsRestaurado = "1";
}
input.focus({ preventScroll: true });
try {
input.setSelectionRange(foco.selectionStart, foco.selectionEnd);
} catch (e) {
// posição de cursor inválida pro valor atual: mantém a padrão
}
}
}
// Rolagem por último: o foco acima não pode mexer na posição restaurada.
panelsEl.querySelectorAll("[data-ips-tab-panel]").forEach((painel) => {
const wrap = painel.querySelector(".ips-table-scroll");
const estado = estadoScrollPaineis[painel.getAttribute("data-ips-tab-panel")];
if (!wrap || !estado || painel.hidden) return;
wrap.scrollTop = estado.scrollTop;
wrap.scrollLeft = estado.scrollLeft;
});
}
function renderPanels() { function renderPanels() {
const tipos = importacaoAtual.tipos_lancamento || []; const tipos = importacaoAtual.tipos_lancamento || [];
const foco = capturarEstadoPaineis();
panelsEl.innerHTML = [...tipos, "auditoria", "alteracoes"] panelsEl.innerHTML = [...tipos, "auditoria", "alteracoes"]
.map((key) => { .map((key) => {
const html = const html =
@ -2249,6 +2539,7 @@ document.addEventListener("DOMContentLoaded", async () => {
.join(""); .join("");
wireColumnResize(panelsEl); wireColumnResize(panelsEl);
wireAutoExpandScroll(panelsEl); wireAutoExpandScroll(panelsEl);
restaurarEstadoPaineis(foco);
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -2348,8 +2639,8 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
if (newBtn) newBtn.addEventListener("click", openNewForm); if (newBtn) newBtn.addEventListener("click", openNewForm);
if (formCancelBtn) formCancelBtn.addEventListener("click", () => showView("list")); if (formCancelBtn) formCancelBtn.addEventListener("click", sairDaNovaImportacao);
if (formBackBtn) formBackBtn.addEventListener("click", () => showView("list")); if (formBackBtn) formBackBtn.addEventListener("click", sairDaNovaImportacao);
if (reviewBackBtn) { if (reviewBackBtn) {
reviewBackBtn.addEventListener("click", async () => { reviewBackBtn.addEventListener("click", async () => {
showView("list"); showView("list");
@ -2373,17 +2664,16 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
if (regracadBtn) regracadBtn.addEventListener("click", abrirCadastroRegras); if (regracadBtn) regracadBtn.addEventListener("click", abrirCadastroRegras);
if (regracadFecharBtn) regracadFecharBtn.addEventListener("click", fecharCadastroRegras); // "Fechar" e clique fora seguem a mesma regra: só perguntam se houver
// alteração de verdade no formulário aberto (comparado com o retrato
// tirado ao abrir a regra, ver regracadTemAlteracao()). Antes, "Fechar"
// descartava sem avisar e o clique fora perguntava sempre, mesmo sem
// nada editado (revisão de interface de 2026-09-30).
if (regracadFecharBtn) regracadFecharBtn.addEventListener("click", tentarFecharCadastroRegras);
if (regracadModal) { if (regracadModal) {
// Diferente dos outros modais do app: este formulário costuma ter edição regracadModal.addEventListener("click", (event) => {
// em andamento (custeio, observações) — clicar fora por engano não deve
// descartar isso sem perguntar (pidConfirm, não window.confirm — ver
// static/js/confirm-modal.js).
regracadModal.addEventListener("click", async (event) => {
if (event.target !== regracadModal) return; if (event.target !== regracadModal) return;
if (await pidConfirm("Fechar o cadastro de regras sem salvar as alterações?", { perigoso: true })) { tentarFecharCadastroRegras();
fecharCadastroRegras();
}
}); });
} }
@ -2459,9 +2749,10 @@ document.addEventListener("DOMContentLoaded", async () => {
regracadSalvarBtn.addEventListener("click", async () => { regracadSalvarBtn.addEventListener("click", async () => {
if (!regracadRegraEmEdicao) return; if (!regracadRegraEmEdicao) return;
const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual(); const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual();
const erro = mensagemErroCusteio(tipos_lancamento); const erro = erroCusteio(tipos_lancamento);
if (erro) { if (erro) {
regracadError.textContent = erro; regracadError.textContent = erro.mensagem;
focarCampoComErro(erro.campo);
return; return;
} }
const payload = { const payload = {
@ -2473,6 +2764,8 @@ document.addEventListener("DOMContentLoaded", async () => {
observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "", observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "",
}; };
regracadSalvarBtn.disabled = true; regracadSalvarBtn.disabled = true;
const rotuloSalvar = regracadSalvarBtn.textContent;
regracadSalvarBtn.textContent = "Salvando…";
try { try {
await pidAtualizarRegraCusteioPlanoSaude(regracadRegraEmEdicao.id, payload); await pidAtualizarRegraCusteioPlanoSaude(regracadRegraEmEdicao.id, payload);
await refreshRegras(); await refreshRegras();
@ -2483,6 +2776,7 @@ document.addEventListener("DOMContentLoaded", async () => {
regracadError.textContent = e.message; regracadError.textContent = e.message;
} finally { } finally {
regracadSalvarBtn.disabled = false; regracadSalvarBtn.disabled = false;
regracadSalvarBtn.textContent = rotuloSalvar;
} }
}); });
} }
@ -2518,16 +2812,19 @@ document.addEventListener("DOMContentLoaded", async () => {
const codigoEmpresa = regracadNovoCodigoEmpresa ? regracadNovoCodigoEmpresa.value.trim() : ""; const codigoEmpresa = regracadNovoCodigoEmpresa ? regracadNovoCodigoEmpresa.value.trim() : "";
if (!codigoEmpresa) { if (!codigoEmpresa) {
regracadError.textContent = "Informe o código da empresa."; regracadError.textContent = "Informe o código da empresa.";
focarCampoComErro(regracadNovoCodigoEmpresa);
return; return;
} }
if (!regracadNovoOperadoraKey) { if (!regracadNovoOperadoraKey) {
regracadError.textContent = "Selecione a operadora."; regracadError.textContent = "Selecione a operadora.";
focarCampoComErro(regracadNovoOperadoraSearch);
return; return;
} }
const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual(); const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual();
const erro = mensagemErroCusteio(tipos_lancamento); const erro = erroCusteio(tipos_lancamento);
if (erro) { if (erro) {
regracadError.textContent = erro; regracadError.textContent = erro.mensagem;
focarCampoComErro(erro.campo);
return; return;
} }
const payload = { const payload = {
@ -2539,6 +2836,8 @@ document.addEventListener("DOMContentLoaded", async () => {
observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "", observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "",
}; };
regracadCriarBtn.disabled = true; regracadCriarBtn.disabled = true;
const rotuloCriar = regracadCriarBtn.textContent;
regracadCriarBtn.textContent = "Salvando…";
try { try {
await pidCriarRegraCusteioPlanoSaude(payload); await pidCriarRegraCusteioPlanoSaude(payload);
await refreshRegras(); await refreshRegras();
@ -2549,6 +2848,7 @@ document.addEventListener("DOMContentLoaded", async () => {
regracadError.textContent = e.message; regracadError.textContent = e.message;
} finally { } finally {
regracadCriarBtn.disabled = false; regracadCriarBtn.disabled = false;
regracadCriarBtn.textContent = rotuloCriar;
} }
}); });
} }
@ -2594,6 +2894,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const row = input.closest("[data-linha-id]"); const row = input.closest("[data-linha-id]");
const linhaId = Number(row.getAttribute("data-linha-id")); const linhaId = Number(row.getAttribute("data-linha-id"));
const campo = input.getAttribute("data-linha-campo"); const campo = input.getAttribute("data-linha-campo");
// Este input já está sendo salvo: o focusout abaixo não precisa
// reenviar, e o redesenho não deve repor nele o texto digitado (ver
// capturarEstadoPaineis()).
delete input.dataset.ipsRestaurado;
input.dataset.ipsSalvando = "1";
let valorDigitado = input.value.trim(); let valorDigitado = input.value.trim();
if (PID_IPS_CAMPOS_EXPRESSAO_VALOR.has(campo)) { if (PID_IPS_CAMPOS_EXPRESSAO_VALOR.has(campo)) {
const resultado = pidAvaliaExpressaoValorMonetario(valorDigitado); const resultado = pidAvaliaExpressaoValorMonetario(valorDigitado);
@ -2616,6 +2921,29 @@ document.addEventListener("DOMContentLoaded", async () => {
renderTabs(); renderTabs();
} catch (e) { } catch (e) {
reviewError.textContent = e.message; reviewError.textContent = e.message;
delete input.dataset.ipsSalvando;
}
});
// Célula cujo texto digitado foi reposto depois de um redesenho (ver
// restaurarEstadoPaineis()): como o valor foi posto por código, o
// navegador não dispara "change" ao sair dela se o usuário não digitar
// mais nada. Aqui o salvamento é disparado na mão, pelo mesmo handler
// de "change" acima (que remove a marca antes, evitando envio duplo).
// Voltou a digitar numa célula cujo salvamento ainda está em andamento:
// a partir daqui o texto dela é do usuário de novo e precisa sobreviver
// ao redesenho (ver `digitado` em capturarEstadoPaineis()).
panelsEl.addEventListener("input", (event) => {
const input = event.target.closest("[data-linha-campo]");
if (input) delete input.dataset.ipsSalvando;
});
panelsEl.addEventListener("focusout", (event) => {
const input = event.target.closest("[data-linha-campo]");
if (!input || input.dataset.ipsRestaurado !== "1") return;
delete input.dataset.ipsRestaurado;
if (input.value !== input.defaultValue) {
input.dispatchEvent(new Event("change", { bubbles: true }));
} }
}); });
@ -2733,6 +3061,8 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
let abrindoImportacao = false;
if (listBody) { if (listBody) {
listBody.addEventListener("click", async (event) => { listBody.addEventListener("click", async (event) => {
const removeBtn = event.target.closest("[data-remover-importacao]"); const removeBtn = event.target.closest("[data-remover-importacao]");
@ -2754,12 +3084,21 @@ document.addEventListener("DOMContentLoaded", async () => {
const row = event.target.closest("[data-importacao-id]"); const row = event.target.closest("[data-importacao-id]");
if (!row) return; if (!row) return;
// Uma abertura por vez: clique duplo (ou clique em outra linha antes
// da primeira carregar) não dispara outra busca; a linha clicada fica
// marcada como carregando até a Revisão abrir.
if (abrindoImportacao) return;
abrindoImportacao = true;
row.classList.add("ips-list-row--carregando");
const id = Number(row.getAttribute("data-importacao-id")); const id = Number(row.getAttribute("data-importacao-id"));
try { try {
const detalhe = await pidFetchImportacaoPlanoSaude(id); const detalhe = await pidFetchImportacaoPlanoSaude(id);
abrirRevisao(detalhe); abrirRevisao(detalhe);
} catch (e) { } catch (e) {
await pidAlert(e.message); await pidAlert(e.message);
} finally {
abrindoImportacao = false;
row.classList.remove("ips-list-row--carregando");
} }
}); });
@ -2848,7 +3187,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" stroke-linejoin="round"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" stroke-linejoin="round"/></svg>
</button> </button>
<div class="ips-col-filter__popup" hidden> <div class="ips-col-filter__popup" hidden>
<input type="text" class="checklist-search" placeholder="Buscar..." /> <input type="text" class="checklist-search" placeholder="Buscar…" />
<label class="modal-checkbox checklist-select-all"><input type="checkbox" checked /> Selecionar todos</label> <label class="modal-checkbox checklist-select-all"><input type="checkbox" checked /> Selecionar todos</label>
<div class="checklist-box"></div> <div class="checklist-box"></div>
<div class="ips-col-filter__actions"> <div class="ips-col-filter__actions">
@ -3014,6 +3353,7 @@ document.addEventListener("DOMContentLoaded", async () => {
formSubmitBtn.addEventListener("click", async () => { formSubmitBtn.addEventListener("click", async () => {
if (!regraResolvidaAtual) { if (!regraResolvidaAtual) {
formError.textContent = "Selecione a empresa e a operadora."; formError.textContent = "Selecione a empresa e a operadora.";
focarCampoComErro(impEmpresaValor.value ? impOperadoraSearch : impEmpresaSearch);
return; return;
} }
const origemQuestor = planilhaOrigemAtual() === "questor"; const origemQuestor = planilhaOrigemAtual() === "questor";
@ -3022,24 +3362,29 @@ document.addEventListener("DOMContentLoaded", async () => {
competenciaIso = competenciaParaIso(formCompetencia.value); competenciaIso = competenciaParaIso(formCompetencia.value);
if (!competenciaIso) { if (!competenciaIso) {
formError.textContent = "Informe a competência no formato MM/AAAA para buscar a planilha no Questor."; formError.textContent = "Informe a competência no formato MM/AAAA para buscar a planilha no Questor.";
focarCampoComErro(formCompetencia);
return; return;
} }
} else { } else {
if (!formPlanilha.files[0]) { if (!formPlanilha.files[0]) {
formError.textContent = "Anexe a planilha padrão do Questor."; formError.textContent = "Anexe a planilha padrão do Questor.";
focarCampoComErro(planilhaUploadField);
return; return;
} }
if (validadorPlanilha.ehInvalido()) { if (validadorPlanilha.ehInvalido()) {
formError.textContent = "Corrija a planilha anexada (veja a mensagem abaixo do campo) antes de processar."; formError.textContent = "Corrija a planilha anexada (veja a mensagem abaixo do campo) antes de processar.";
focarCampoComErro(formPlanilhaStatus);
return; return;
} }
} }
if (validadorArquivo.ehVazio()) { if (validadorArquivo.ehVazio()) {
formError.textContent = "Anexe ao menos um arquivo da operadora."; formError.textContent = "Anexe ao menos um arquivo da operadora.";
focarCampoComErro(formArquivo.closest(".ips-file-field"));
return; return;
} }
if (validadorArquivo.ehInvalido()) { if (validadorArquivo.ehInvalido()) {
formError.textContent = "Corrija o(s) arquivo(s) da operadora (veja a mensagem abaixo de cada um) antes de processar."; formError.textContent = "Corrija o(s) arquivo(s) da operadora (veja a mensagem abaixo de cada um) antes de processar.";
focarCampoComErro(formArquivoList);
return; return;
} }
formError.textContent = ""; formError.textContent = "";
@ -3054,7 +3399,7 @@ document.addEventListener("DOMContentLoaded", async () => {
montarFormDataDeRegra(formData, regraResolvidaAtual); montarFormDataDeRegra(formData, regraResolvidaAtual);
formSubmitBtn.disabled = true; formSubmitBtn.disabled = true;
formSubmitBtn.textContent = "Processando..."; formSubmitBtn.textContent = "Processando…";
try { try {
const importacao = await pidCriarImportacaoPlanoSaude(formData); const importacao = await pidCriarImportacaoPlanoSaude(formData);
abrirRevisao(importacao); abrirRevisao(importacao);

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,11 @@ document.addEventListener("DOMContentLoaded", async () => {
let links = []; let links = [];
let favoritos = new Set(); let favoritos = new Set();
let editingId = null; let editingId = null;
// Retrato dos campos na abertura do modal, pra só perguntar "Sair sem
// salvar" quando algo mudou de fato.
let retratoModal = null;
const textoVazio = emptyEl ? emptyEl.textContent : "";
const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
function sortFavoritesFirst(list) { function sortFavoritesFirst(list) {
const fav = []; const fav = [];
@ -80,7 +85,10 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function cardHtml(link, index) { function cardHtml(link, index) {
const iconHtml = link.icone ? `<img class="lf-card__icon" src="${pidEscapeHtml(link.icone)}" alt="" />` : PID_LINK_DEFAULT_ICON; // Tamanho fixo (115px em .lf-card__icon) evita a grade pular enquanto a imagem carrega.
const iconHtml = link.icone
? `<img class="lf-card__icon" src="${pidEscapeHtml(link.icone)}" alt="" width="115" height="115" loading="lazy" />`
: PID_LINK_DEFAULT_ICON;
const isFavorite = favoritos.has(link.id); const isFavorite = favoritos.has(link.id);
const favoriteBtn = ` const favoriteBtn = `
@ -135,13 +143,25 @@ document.addEventListener("DOMContentLoaded", async () => {
rawLinks = fetchedLinks; rawLinks = fetchedLinks;
favoritos = new Set(favoriteIds); favoritos = new Set(favoriteIds);
} catch (e) { } catch (e) {
// Falha de carga não pode parecer "nenhum link cadastrado".
rawLinks = []; rawLinks = [];
favoritos = new Set(); links = [];
render();
if (emptyEl) {
emptyEl.textContent = msgErroCarga;
emptyEl.hidden = false;
} }
return;
}
if (emptyEl) emptyEl.textContent = textoVazio;
links = sortFavoritesFirst(rawLinks); links = sortFavoritesFirst(rawLinks);
render(); render();
} }
function retratoAtual() {
return JSON.stringify([formNome.value, formUrl.value, formIcone.value]);
}
function openModal(link) { function openModal(link) {
editingId = link ? link.id : null; editingId = link ? link.id : null;
formNome.value = link ? link.nome : ""; formNome.value = link ? link.nome : "";
@ -150,9 +170,10 @@ document.addEventListener("DOMContentLoaded", async () => {
formError.textContent = ""; formError.textContent = "";
formTitle.textContent = link ? "Editar Link" : "Adicionar Link"; formTitle.textContent = link ? "Editar Link" : "Adicionar Link";
formIconeLabel.textContent = link formIconeLabel.textContent = link
? "Ícone (deixe em branco para manter o atual, até 2MB)" ? "Ícone (deixe em branco para manter o atual, até 2 MB)"
: "Ícone (opcional, até 2MB)"; : "Ícone (opcional, até 2 MB)";
saveBtn.textContent = link ? "Salvar" : "Adicionar"; saveBtn.textContent = link ? "Salvar" : "Adicionar";
retratoModal = retratoAtual();
modal.hidden = false; modal.hidden = false;
formNome.focus(); formNome.focus();
} }
@ -160,10 +181,30 @@ document.addEventListener("DOMContentLoaded", async () => {
function closeModal() { function closeModal() {
modal.hidden = true; modal.hidden = true;
editingId = null; editingId = null;
retratoModal = null;
} }
async function swapOrdem(a, b) { async function tentarFecharModal() {
if (retratoModal !== null && retratoAtual() !== retratoModal) {
const sair = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (!sair) return;
}
closeModal();
}
// Na falha, uma das duas gravações pode ter passado: recarrega pra tela
// mostrar a ordem real do servidor.
async function swapOrdem(a, b, botao) {
botao.disabled = true;
try {
await Promise.all([pidUpdateLinkOrdem(a.id, b.ordem), pidUpdateLinkOrdem(b.id, a.ordem)]); await Promise.all([pidUpdateLinkOrdem(a.id, b.ordem), pidUpdateLinkOrdem(b.id, a.ordem)]);
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível mover o link" });
}
await refresh(); await refresh();
} }
@ -175,7 +216,7 @@ document.addEventListener("DOMContentLoaded", async () => {
formIcone.addEventListener("change", () => { formIcone.addEventListener("change", () => {
const file = formIcone.files[0]; const file = formIcone.files[0];
if (file && file.size > PID_LINK_ICON_MAX_BYTES) { if (file && file.size > PID_LINK_ICON_MAX_BYTES) {
formError.textContent = "O ícone deve ter no máximo 2MB."; formError.textContent = "O ícone deve ter no máximo 2 MB.";
formIcone.value = ""; formIcone.value = "";
} else { } else {
formError.textContent = ""; formError.textContent = "";
@ -183,11 +224,11 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
if (cancelBtn) cancelBtn.addEventListener("click", closeModal); if (cancelBtn) cancelBtn.addEventListener("click", tentarFecharModal);
if (modal) { if (modal) {
modal.addEventListener("click", (event) => { modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal(); if (event.target === modal) tentarFecharModal();
}); });
} }
@ -197,10 +238,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const url = formUrl.value.trim(); const url = formUrl.value.trim();
if (!nome || !url) { if (!nome || !url) {
formError.textContent = "Informe o nome e o link."; formError.textContent = "Informe o nome e o link.";
(nome ? formUrl : formNome).focus();
return; return;
} }
if (formIcone.files[0] && formIcone.files[0].size > PID_LINK_ICON_MAX_BYTES) { if (formIcone.files[0] && formIcone.files[0].size > PID_LINK_ICON_MAX_BYTES) {
formError.textContent = "O ícone deve ter no máximo 2MB."; formError.textContent = "O ícone deve ter no máximo 2 MB.";
return; return;
} }
formError.textContent = ""; formError.textContent = "";
@ -210,6 +252,10 @@ document.addEventListener("DOMContentLoaded", async () => {
formData.append("url", url); formData.append("url", url);
if (formIcone.files[0]) formData.append("icone", formIcone.files[0]); if (formIcone.files[0]) formData.append("icone", formIcone.files[0]);
// Trava contra clique duplo (o upload do ícone pode demorar).
const textoOriginal = saveBtn.textContent;
saveBtn.disabled = true;
saveBtn.textContent = editingId ? "Salvando…" : "Adicionando…";
try { try {
if (editingId) { if (editingId) {
await pidUpdateLink(editingId, formData); await pidUpdateLink(editingId, formData);
@ -220,6 +266,9 @@ document.addEventListener("DOMContentLoaded", async () => {
await refresh(); await refresh();
} catch (e) { } catch (e) {
formError.textContent = e.message; formError.textContent = e.message;
} finally {
saveBtn.disabled = false;
saveBtn.textContent = textoOriginal;
} }
}); });
} }
@ -228,6 +277,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const favoriteBtn = event.target.closest("[data-link-favorite]"); const favoriteBtn = event.target.closest("[data-link-favorite]");
if (favoriteBtn) { if (favoriteBtn) {
const id = Number(favoriteBtn.getAttribute("data-link-favorite")); const id = Number(favoriteBtn.getAttribute("data-link-favorite"));
// Trava a estrela: dois cliques rápidos mandavam POST duas vezes.
if (favoriteBtn.disabled) return;
favoriteBtn.disabled = true;
try { try {
if (favoritos.has(id)) { if (favoritos.has(id)) {
await pidRemoveLinkFavorito(id); await pidRemoveLinkFavorito(id);
@ -239,7 +291,8 @@ document.addEventListener("DOMContentLoaded", async () => {
links = sortFavoritesFirst(rawLinks); links = sortFavoritesFirst(rawLinks);
render(); render();
} catch (e) { } catch (e) {
await pidAlert(e.message); favoriteBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível atualizar o favorito" });
} }
return; return;
} }
@ -257,7 +310,14 @@ document.addEventListener("DOMContentLoaded", async () => {
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 && (await pidConfirm(`Remover o link "${link.nome}"?`, { perigoso: true }))) { if (link && (await pidConfirm(`Remover o link "${link.nome}"?`, { perigoso: true }))) {
removeBtn.disabled = true;
try {
await pidDeleteLink(id); await pidDeleteLink(id);
} catch (e) {
removeBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível remover o link" });
return;
}
await refresh(); await refresh();
} }
return; return;
@ -267,7 +327,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (upBtn) { if (upBtn) {
const id = Number(upBtn.getAttribute("data-link-move-up")); const id = Number(upBtn.getAttribute("data-link-move-up"));
const index = links.findIndex((l) => l.id === id); const index = links.findIndex((l) => l.id === id);
if (index > 0) await swapOrdem(links[index], links[index - 1]); if (index > 0) await swapOrdem(links[index], links[index - 1], upBtn);
return; return;
} }
@ -275,7 +335,7 @@ document.addEventListener("DOMContentLoaded", async () => {
if (downBtn) { if (downBtn) {
const id = Number(downBtn.getAttribute("data-link-move-down")); const id = Number(downBtn.getAttribute("data-link-move-down"));
const index = links.findIndex((l) => l.id === id); const index = links.findIndex((l) => l.id === id);
if (index !== -1 && index < links.length - 1) await swapOrdem(links[index], links[index + 1]); if (index !== -1 && index < links.length - 1) await swapOrdem(links[index], links[index + 1], downBtn);
} }
}); });
@ -336,7 +396,12 @@ document.addEventListener("DOMContentLoaded", async () => {
links = reordered; links = reordered;
render(); render();
try {
await Promise.all(updates.map(({ link, index }) => pidUpdateLinkOrdem(link.id, index))); await Promise.all(updates.map(({ link, index }) => pidUpdateLinkOrdem(link.id, index)));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível reordenar os links" });
}
// Sempre recarrega: na falha, desfaz a ordem que a tela mostrou antes da hora.
await refresh(); await refresh();
}); });

View File

@ -106,8 +106,10 @@ function pidNcfBindSortHeaders(tableId, getSortState, setSortState, rerender) {
/** Botão de expandir (chevron que gira) + linha de detalhe associada, mesmo /** Botão de expandir (chevron que gira) + linha de detalhe associada, mesmo
* padrão de `.cc-painel-fiscal__toggle` (custo-contratacao.js), adaptado pra * padrão de `.cc-painel-fiscal__toggle` (custo-contratacao.js), adaptado pra
* uma linha de tabela — `montarConteudo(painelEl)` só roda na primeira vez * uma linha de tabela — `montarConteudo(painelEl)` só roda na primeira vez
* que a linha é aberta (lazy). */ * que a linha é aberta (lazy). `aberto` já cria a linha expandida (reabre o
function pidNcfCriarLinhaExpandivel(colspan, montarConteudo) { * que estava aberto antes de um redesenho) e `aoAlternar(aberto)` avisa quem
* guarda esse estado. */
function pidNcfCriarLinhaExpandivel(colspan, montarConteudo, { aberto: abertoInicial = false, aoAlternar } = {}) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "ncf-expand-btn"; btn.className = "ncf-expand-btn";
@ -130,19 +132,71 @@ function pidNcfCriarLinhaExpandivel(colspan, montarConteudo) {
td.appendChild(painel); td.appendChild(painel);
trDetalhe.appendChild(td); trDetalhe.appendChild(td);
btn.addEventListener("click", async () => { async function abrirOuFechar(abrir) {
const aberto = btn.getAttribute("aria-expanded") === "true"; btn.setAttribute("aria-expanded", String(abrir));
btn.setAttribute("aria-expanded", String(!aberto)); trDetalhe.hidden = !abrir;
trDetalhe.hidden = aberto; if (abrir && !painel.dataset.carregado) {
if (!aberto && !painel.dataset.carregado) {
painel.dataset.carregado = "1"; painel.dataset.carregado = "1";
await montarConteudo(painel); await montarConteudo(painel);
} }
}
btn.addEventListener("click", async () => {
const abrir = btn.getAttribute("aria-expanded") !== "true";
if (aoAlternar) aoAlternar(abrir);
await abrirOuFechar(abrir);
}); });
if (abertoInicial) abrirOuFechar(true);
return { btn, trDetalhe }; return { btn, trDetalhe };
} }
const PID_NCF_MSG_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
// Dias médios do Dashboard (o backend arredonda pra 1 casa) em pt-BR.
const PID_NCF_DIAS = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 });
/** Trava o botão "Marcar como tratado"/"Reabrir" até a resposta (sem envio
* duplo); a lista é redesenhada depois, então só restaura se o botão
* ainda estiver na tela. */
async function pidNcfComBotaoOcupado(btn, textoOcupado, acao) {
if (btn.disabled) return;
const textoOriginal = btn.textContent;
btn.disabled = true;
btn.textContent = textoOcupado;
try {
await acao();
} finally {
if (btn.isConnected) {
btn.disabled = false;
btn.textContent = textoOriginal;
}
}
}
/** Guarda os ids das linhas abertas de uma tabela, pra reabri-las depois de
* um redesenho (busca, ordenação, marcar como tratado). */
function pidNcfOpcoesExpansao(conjunto, id) {
return {
aberto: conjunto.has(id),
aoAlternar: (aberto) => {
if (aberto) conjunto.add(id);
else conjunto.delete(id);
},
};
}
/** Texto do estado vazio de uma lista: a mensagem de "nenhum item" original
* (guardada na primeira chamada) ou a de erro de carga, que nunca pode
* parecer lista vazia. */
function pidNcfEstadoVazio(emptyEl, erroCarga, temItens) {
if (emptyEl.dataset.textoVazio == null) emptyEl.dataset.textoVazio = emptyEl.textContent;
emptyEl.textContent = erroCarga ? PID_NCF_MSG_ERRO_CARGA : emptyEl.dataset.textoVazio;
emptyEl.classList.toggle("ncf-empty--erro", erroCarga);
emptyEl.hidden = temItens && !erroCarga;
}
function pidNcfCriarBadgeTratativa(item) { function pidNcfCriarBadgeTratativa(item) {
const fragmento = document.createDocumentFragment(); const fragmento = document.createDocumentFragment();
const tratado = item.status_tratativa === "tratado"; const tratado = item.status_tratativa === "tratado";
@ -203,9 +257,10 @@ document.addEventListener("DOMContentLoaded", async () => {
const nome = btn.dataset.ncfTab; const nome = btn.dataset.ncfTab;
ativarTab(nome); ativarTab(nome);
if (nome === "gestao") await garantirGestaoCarregada(); if (nome === "gestao") await garantirGestaoCarregada();
// Só marca como carregado depois do sucesso: se falhar, abrir a aba de
// novo tenta outra vez (antes ficava vazia até recarregar a página).
if (nome === "importar" && !importarCarregado) { if (nome === "importar" && !importarCarregado) {
importarCarregado = true; importarCarregado = await carregarImportacoes();
await carregarImportacoes();
} }
}) })
); );
@ -226,6 +281,22 @@ document.addEventListener("DOMContentLoaded", async () => {
let analiseSemAcaoTratativaFiltroAtual = "pendente"; let analiseSemAcaoTratativaFiltroAtual = "pendente";
let buscaGestao = ""; let buscaGestao = "";
// Contador por lista: cada carga leva um número e só a mais recente
// escreve na tela. Cliques rápidos nos chips disparam várias cargas e,
// sem isso, a resposta antiga que chegasse por último vencia.
let seqSemAnalise = 0;
let seqAnaliseSemAcao = 0;
let seqAcoes = 0;
let seqDashboard = 0;
// Erro de carga por lista: mostrado no lugar da mensagem de lista vazia.
let erroCargaSemAnalise = false;
let erroCargaAnaliseSemAcao = false;
let erroCargaAcoes = false;
// Linhas abertas de cada tabela (ids), reabertas a cada redesenho.
const expandidosSemAnalise = new Set();
const expandidosAnaliseSemAcao = new Set();
const expandidosAcoes = new Set();
// Ordenação por clique no cabeçalho — estado independente por tabela. // Ordenação por clique no cabeçalho — estado independente por tabela.
// sortKey=null preserva a ordenação vinda do servidor até o usuário clicar // sortKey=null preserva a ordenação vinda do servidor até o usuário clicar
// um cabeçalho pela primeira vez. // um cabeçalho pela primeira vez.
@ -272,15 +343,22 @@ document.addEventListener("DOMContentLoaded", async () => {
renderAcoes(); renderAcoes();
} }
let buscaTimer = null;
function aplicarBusca(valor) { function aplicarBusca(valor) {
clearTimeout(buscaTimer);
buscaGestao = valor; buscaGestao = valor;
gestaoSearch.value = valor; gestaoSearch.value = valor;
gestaoSearchClearBtn.hidden = !valor; gestaoSearchClearBtn.hidden = !valor;
renderTudoGestao(); renderTudoGestao();
} }
// Digitação redesenha as 3 tabelas só depois de ~200 ms sem tecla nova
// (antes era a cada tecla). Limpar e o drill-down aplicam na hora.
gestaoSearch.addEventListener("input", () => { gestaoSearch.addEventListener("input", () => {
aplicarBusca(gestaoSearch.value); gestaoSearchClearBtn.hidden = !gestaoSearch.value;
clearTimeout(buscaTimer);
buscaTimer = setTimeout(() => aplicarBusca(gestaoSearch.value), 200);
}); });
gestaoSearchClearBtn.addEventListener("click", () => aplicarBusca("")); gestaoSearchClearBtn.addEventListener("click", () => aplicarBusca(""));
@ -330,10 +408,24 @@ document.addEventListener("DOMContentLoaded", async () => {
carregarAnaliseSemAcao(); carregarAnaliseSemAcao();
}); });
// As cargas abaixo (carregarSemAnalise/carregarAnaliseSemAcao/carregarAcoes)
// nunca rejeitam: tratam o próprio erro na tela e devolvem true/false. Por
// isso os chips podem chamá-las sem await. `gestaoCarregada` só vira true
// se as três deram certo; uma carga já em andamento é reaproveitada.
let gestaoCarregandoPromise = null;
async function garantirGestaoCarregada() { async function garantirGestaoCarregada() {
if (gestaoCarregada) return; if (gestaoCarregada) return true;
gestaoCarregada = true; if (!gestaoCarregandoPromise) {
await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao(), carregarAcoes()]); gestaoCarregandoPromise = Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao(), carregarAcoes()])
.then((resultados) => {
gestaoCarregada = resultados.every(Boolean);
return gestaoCarregada;
})
.finally(() => {
gestaoCarregandoPromise = null;
});
}
return gestaoCarregandoPromise;
} }
// ---------------- Gestão: Ocorrências sem análise ---------------- // ---------------- Gestão: Ocorrências sem análise ----------------
@ -359,10 +451,24 @@ document.addEventListener("DOMContentLoaded", async () => {
); );
async function carregarSemAnalise() { async function carregarSemAnalise() {
const seq = ++seqSemAnalise;
const params = new URLSearchParams({ sem_analise: "true" }); const params = new URLSearchParams({ sem_analise: "true" });
if (semAnaliseTratativaFiltroAtual !== "todas") params.set("status_tratativa", semAnaliseTratativaFiltroAtual); if (semAnaliseTratativaFiltroAtual !== "todas") params.set("status_tratativa", semAnaliseTratativaFiltroAtual);
semAnaliseTodos = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`); let itens;
try {
itens = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`);
} catch (e) {
if (seq !== seqSemAnalise) return true; // carga mais nova já assumiu
semAnaliseTodos = [];
erroCargaSemAnalise = true;
renderSemAnalise(); renderSemAnalise();
return false;
}
if (seq !== seqSemAnalise) return true;
semAnaliseTodos = itens;
erroCargaSemAnalise = false;
renderSemAnalise();
return true;
} }
function renderSemAnalise() { function renderSemAnalise() {
@ -372,6 +478,7 @@ document.addEventListener("DOMContentLoaded", async () => {
"clientes_relacionados", "clientes_relacionados",
"assunto", "assunto",
"emissor_relato", "emissor_relato",
"tipos_causa",
]); ]);
itens = pidNcfAplicarSort(itens, semAnaliseSortKey, semAnaliseSortDir, PID_NCF_SEM_ANALISE_SORT_VALOR); itens = pidNcfAplicarSort(itens, semAnaliseSortKey, semAnaliseSortDir, PID_NCF_SEM_ANALISE_SORT_VALOR);
pidNcfAtualizarSortHeaders(semAnaliseSortHeaders, semAnaliseSortKey, semAnaliseSortDir); pidNcfAtualizarSortHeaders(semAnaliseSortHeaders, semAnaliseSortKey, semAnaliseSortDir);
@ -379,10 +486,14 @@ document.addEventListener("DOMContentLoaded", async () => {
atualizarResumoGestao(); atualizarResumoGestao();
pidNcfAtualizarContador(semAnaliseCount, itens.length); pidNcfAtualizarContador(semAnaliseCount, itens.length);
semAnaliseBody.innerHTML = ""; semAnaliseBody.innerHTML = "";
semAnaliseEmpty.hidden = itens.length > 0; pidNcfEstadoVazio(semAnaliseEmpty, erroCargaSemAnalise, itens.length > 0);
itens.forEach((oc) => { itens.forEach((oc) => {
const tratado = oc.status_tratativa === "tratado"; const tratado = oc.status_tratativa === "tratado";
const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(8, (painel) => montarPainelOcorrencia(painel, oc)); const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(
8,
(painel) => montarPainelOcorrencia(painel, oc),
pidNcfOpcoesExpansao(expandidosSemAnalise, oc.id)
);
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const tdBtn = document.createElement("td"); const tdBtn = document.createElement("td");
@ -405,7 +516,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tratarBtn.type = "button"; tratarBtn.type = "button";
tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; tratarBtn.className = tratado ? "btn-outline" : "btn-solid";
tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado";
tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado)); tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado, tratarBtn));
tdAcao.appendChild(tratarBtn); tdAcao.appendChild(tratarBtn);
tr.appendChild(tdAcao); tr.appendChild(tdAcao);
@ -437,10 +548,24 @@ document.addEventListener("DOMContentLoaded", async () => {
); );
async function carregarAnaliseSemAcao() { async function carregarAnaliseSemAcao() {
const seq = ++seqAnaliseSemAcao;
const params = new URLSearchParams({ analise_sem_acao: "true" }); const params = new URLSearchParams({ analise_sem_acao: "true" });
if (analiseSemAcaoTratativaFiltroAtual !== "todas") params.set("status_tratativa", analiseSemAcaoTratativaFiltroAtual); if (analiseSemAcaoTratativaFiltroAtual !== "todas") params.set("status_tratativa", analiseSemAcaoTratativaFiltroAtual);
analiseSemAcaoTodos = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`); let itens;
try {
itens = await pidApiRequest(`/nao-conformidades/ocorrencias/?${params.toString()}`);
} catch (e) {
if (seq !== seqAnaliseSemAcao) return true;
analiseSemAcaoTodos = [];
erroCargaAnaliseSemAcao = true;
renderAnaliseSemAcao(); renderAnaliseSemAcao();
return false;
}
if (seq !== seqAnaliseSemAcao) return true;
analiseSemAcaoTodos = itens;
erroCargaAnaliseSemAcao = false;
renderAnaliseSemAcao();
return true;
} }
function renderAnaliseSemAcao() { function renderAnaliseSemAcao() {
@ -450,6 +575,7 @@ document.addEventListener("DOMContentLoaded", async () => {
"clientes_relacionados", "clientes_relacionados",
"assunto", "assunto",
"descricao_analise", "descricao_analise",
"tipos_causa",
]); ]);
itens = pidNcfAplicarSort(itens, analiseSemAcaoSortKey, analiseSemAcaoSortDir, PID_NCF_ANALISE_SEM_ACAO_SORT_VALOR); itens = pidNcfAplicarSort(itens, analiseSemAcaoSortKey, analiseSemAcaoSortDir, PID_NCF_ANALISE_SEM_ACAO_SORT_VALOR);
pidNcfAtualizarSortHeaders(analiseSemAcaoSortHeaders, analiseSemAcaoSortKey, analiseSemAcaoSortDir); pidNcfAtualizarSortHeaders(analiseSemAcaoSortHeaders, analiseSemAcaoSortKey, analiseSemAcaoSortDir);
@ -457,11 +583,15 @@ document.addEventListener("DOMContentLoaded", async () => {
atualizarResumoGestao(); atualizarResumoGestao();
pidNcfAtualizarContador(analiseSemAcaoCount, itens.length); pidNcfAtualizarContador(analiseSemAcaoCount, itens.length);
analiseSemAcaoBody.innerHTML = ""; analiseSemAcaoBody.innerHTML = "";
analiseSemAcaoEmpty.hidden = itens.length > 0; pidNcfEstadoVazio(analiseSemAcaoEmpty, erroCargaAnaliseSemAcao, itens.length > 0);
itens.forEach((oc) => { itens.forEach((oc) => {
const tratado = oc.status_tratativa === "tratado"; const tratado = oc.status_tratativa === "tratado";
const descricao = (oc.descricao_analise || "").slice(0, 140); const descricao = (oc.descricao_analise || "").slice(0, 140);
const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(8, (painel) => montarPainelOcorrencia(painel, oc)); const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(
8,
(painel) => montarPainelOcorrencia(painel, oc),
pidNcfOpcoesExpansao(expandidosAnaliseSemAcao, oc.id)
);
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const tdBtn = document.createElement("td"); const tdBtn = document.createElement("td");
@ -484,7 +614,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tratarBtn.type = "button"; tratarBtn.type = "button";
tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; tratarBtn.className = tratado ? "btn-outline" : "btn-solid";
tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado";
tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado)); tratarBtn.addEventListener("click", () => alternarTratativaOcorrencia(oc.id, tratado, tratarBtn));
tdAcao.appendChild(tratarBtn); tdAcao.appendChild(tratarBtn);
tr.appendChild(tdAcao); tr.appendChild(tdAcao);
@ -493,14 +623,21 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
async function alternarTratativaOcorrencia(id, estaTratado) { // Recarregar a lista depois não fecha as linhas abertas (ver
// expandidos*); uma falha na recarga aparece na própria lista.
async function alternarTratativaOcorrencia(id, estaTratado, btn) {
const acao = estaTratado ? "reabrir" : "marcar-tratado"; const acao = estaTratado ? "reabrir" : "marcar-tratado";
await pidNcfComBotaoOcupado(btn, estaTratado ? "Reabrindo…" : "Marcando…", async () => {
try { try {
await pidApiRequest(`/nao-conformidades/ocorrencias/${id}/${acao}/`, { method: "POST" }); await pidApiRequest(`/nao-conformidades/ocorrencias/${id}/${acao}/`, { method: "POST" });
await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao()]);
} catch (e) { } catch (e) {
await pidAlert(e.message || "Não foi possível atualizar o status."); await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível atualizar a tratativa",
});
return;
} }
await Promise.all([carregarSemAnalise(), carregarAnaliseSemAcao()]);
});
} }
// ---------------- Painel de detalhe de uma ocorrência (lazy) ---------------- // ---------------- Painel de detalhe de uma ocorrência (lazy) ----------------
@ -517,7 +654,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</div> </div>
<h4>Descrição da análise</h4> <h4>Descrição da análise</h4>
<p class="ncf-detail-panel__texto">${pidNcfEscapeHtml(item.descricao_analise) || "(sem análise registrada)"}</p> <p class="ncf-detail-panel__texto">${pidNcfEscapeHtml(item.descricao_analise) || "(sem análise registrada)"}</p>
<div id="${idExtra}"><p class="ncf-detail-panel__loading">Carregando mais detalhes...</p></div> <div id="${idExtra}"><p class="ncf-detail-panel__loading">Carregando mais detalhes…</p></div>
`; `;
const extra = painel.querySelector(`#${idExtra}`); const extra = painel.querySelector(`#${idExtra}`);
try { try {
@ -542,7 +679,11 @@ document.addEventListener("DOMContentLoaded", async () => {
${acoesHtml} ${acoesHtml}
`; `;
} catch (e) { } catch (e) {
extra.innerHTML = `<p class="modal-error">${pidNcfEscapeHtml(e.message || "Erro ao carregar detalhes.")}</p>`; // Libera a próxima abertura pra tentar de novo (o painel é lazy).
delete painel.dataset.carregado;
extra.innerHTML = `<p class="modal-error">${pidNcfEscapeHtml(
`${(e.message || "Não foi possível carregar os detalhes").replace(/\.?$/, ".")} Feche e abra a linha de novo; se persistir, recarregue a página.`
)}</p>`;
} }
} }
@ -572,21 +713,35 @@ document.addEventListener("DOMContentLoaded", async () => {
); );
async function carregarAcoes() { async function carregarAcoes() {
const seq = ++seqAcoes;
if (prazoFiltrosSelecionados.size === 0) { if (prazoFiltrosSelecionados.size === 0) {
// Nenhum chip de Prazo marcado — mesmo espírito de um checklist todo // Nenhum chip de Prazo marcado — mesmo espírito de um checklist todo
// desmarcado, não mostra nada (em vez de interpretar como "sem // desmarcado, não mostra nada (em vez de interpretar como "sem
// filtro" e trazer tudo, o que seria surpreendente pro usuário). // filtro" e trazer tudo, o que seria surpreendente pro usuário).
acoesTodos = []; acoesTodos = [];
erroCargaAcoes = false;
tipoOcorrenciaOpcoesDisponiveis = []; tipoOcorrenciaOpcoesDisponiveis = [];
renderTipoOcorrenciaChecklist(); renderTipoOcorrenciaChecklist();
renderAcoes(); renderAcoes();
return; return true;
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
prazoFiltrosSelecionados.forEach((bucket) => params.append("status_prazo", bucket)); prazoFiltrosSelecionados.forEach((bucket) => params.append("status_prazo", bucket));
if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual); if (tratativaFiltroAtual !== "todas") params.set("status_tratativa", tratativaFiltroAtual);
const query = params.toString(); const query = params.toString();
acoesTodos = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`); let itens;
try {
itens = await pidApiRequest(`/nao-conformidades/acoes/${query ? "?" + query : ""}`);
} catch (e) {
if (seq !== seqAcoes) return true; // carga mais nova já assumiu
acoesTodos = [];
erroCargaAcoes = true;
renderAcoes();
return false;
}
if (seq !== seqAcoes) return true;
acoesTodos = itens;
erroCargaAcoes = false;
// Opções do checklist "Tipo de Ocorrência" vêm do array cheio, ANTES de // Opções do checklist "Tipo de Ocorrência" vêm do array cheio, ANTES de
// qualquer filtro de coluna/busca — recalculado só aqui (uma vez por // qualquer filtro de coluna/busca — recalculado só aqui (uma vez por
@ -601,6 +756,7 @@ document.addEventListener("DOMContentLoaded", async () => {
renderTipoOcorrenciaChecklist(); renderTipoOcorrenciaChecklist();
renderAcoes(); renderAcoes();
return true;
} }
function pidNcfFiltraColunaAcoes(itens) { function pidNcfFiltraColunaAcoes(itens) {
@ -614,7 +770,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function renderAcoes() { function renderAcoes() {
let itens = filtrarPorBusca(acoesTodos, ["executor", "ocorrencia_clientes", "ocorrencia_codigo", "acao_texto"]); let itens = filtrarPorBusca(acoesTodos, ["executor", "ocorrencia_clientes", "ocorrencia_codigo", "acao_texto", "ocorrencia_tipos_causa"]);
itens = pidNcfFiltraColunaAcoes(itens); itens = pidNcfFiltraColunaAcoes(itens);
itens = pidNcfAplicarSort(itens, acoesSortKey, acoesSortDir, PID_NCF_ACOES_SORT_VALOR); itens = pidNcfAplicarSort(itens, acoesSortKey, acoesSortDir, PID_NCF_ACOES_SORT_VALOR);
pidNcfAtualizarSortHeaders(acoesSortHeaders, acoesSortKey, acoesSortDir); pidNcfAtualizarSortHeaders(acoesSortHeaders, acoesSortKey, acoesSortDir);
@ -622,13 +778,17 @@ document.addEventListener("DOMContentLoaded", async () => {
atualizarResumoGestao(); atualizarResumoGestao();
pidNcfAtualizarContador(acoesCount, itens.length); pidNcfAtualizarContador(acoesCount, itens.length);
acoesBody.innerHTML = ""; acoesBody.innerHTML = "";
acoesEmpty.hidden = itens.length > 0; pidNcfEstadoVazio(acoesEmpty, erroCargaAcoes, itens.length > 0);
itens.forEach((acaoItem) => { itens.forEach((acaoItem) => {
const tratado = acaoItem.status_tratativa === "tratado"; const tratado = acaoItem.status_tratativa === "tratado";
// 11 colunas — a descrição da Ação não é mais uma coluna própria (não // 11 colunas — a descrição da Ação não é mais uma coluna própria (não
// cabia mais na largura da tabela junto com as outras); vira uma linha // cabia mais na largura da tabela junto com as outras); vira uma linha
// full-width logo abaixo, ver trDescricao mais adiante. // full-width logo abaixo, ver trDescricao mais adiante.
const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(11, (painel) => montarPainelAcao(painel, acaoItem)); const { btn, trDetalhe } = pidNcfCriarLinhaExpandivel(
11,
(painel) => montarPainelAcao(painel, acaoItem),
pidNcfOpcoesExpansao(expandidosAcoes, acaoItem.id)
);
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "ncf-acao-main-row"; tr.className = "ncf-acao-main-row";
@ -673,7 +833,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tratarBtn.type = "button"; tratarBtn.type = "button";
tratarBtn.className = tratado ? "btn-outline" : "btn-solid"; tratarBtn.className = tratado ? "btn-outline" : "btn-solid";
tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado"; tratarBtn.textContent = tratado ? "Reabrir" : "Marcar como tratado";
tratarBtn.addEventListener("click", () => alternarTratativaAcao(acaoItem.id, tratado)); tratarBtn.addEventListener("click", () => alternarTratativaAcao(acaoItem.id, tratado, tratarBtn));
tdAcao.appendChild(tratarBtn); tdAcao.appendChild(tratarBtn);
} }
tr.appendChild(tdAcao); tr.appendChild(tdAcao);
@ -696,14 +856,19 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
async function alternarTratativaAcao(id, estaTratado) { async function alternarTratativaAcao(id, estaTratado, btn) {
const acao = estaTratado ? "reabrir" : "marcar-tratado"; const acao = estaTratado ? "reabrir" : "marcar-tratado";
await pidNcfComBotaoOcupado(btn, estaTratado ? "Reabrindo…" : "Marcando…", async () => {
try { try {
await pidApiRequest(`/nao-conformidades/acoes/${id}/${acao}/`, { method: "POST" }); await pidApiRequest(`/nao-conformidades/acoes/${id}/${acao}/`, { method: "POST" });
await carregarAcoes();
} catch (e) { } catch (e) {
await pidAlert(e.message || "Não foi possível atualizar o status."); await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível atualizar a tratativa",
});
return;
} }
await carregarAcoes();
});
} }
// ---------------- Painel de detalhe de uma ação (lazy) — histórico ---------------- // ---------------- Painel de detalhe de uma ação (lazy) — histórico ----------------
@ -716,7 +881,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<p class="ncf-detail-panel__texto"><strong>Ação:</strong> ${pidNcfEscapeHtml(item.acao_texto) || "—"}</p> <p class="ncf-detail-panel__texto"><strong>Ação:</strong> ${pidNcfEscapeHtml(item.acao_texto) || "—"}</p>
${finalizadaHtml} ${finalizadaHtml}
<h4>Histórico de acompanhamentos</h4> <h4>Histórico de acompanhamentos</h4>
<div id="${idAcomp}"><p class="ncf-detail-panel__loading">Carregando...</p></div> <div id="${idAcomp}"><p class="ncf-detail-panel__loading">Carregando…</p></div>
`; `;
const container = painel.querySelector(`#${idAcomp}`); const container = painel.querySelector(`#${idAcomp}`);
try { try {
@ -759,7 +924,11 @@ document.addEventListener("DOMContentLoaded", async () => {
container.appendChild(maisBtn); container.appendChild(maisBtn);
} }
} catch (e) { } catch (e) {
container.innerHTML = `<p class="modal-error">${pidNcfEscapeHtml(e.message || "Erro ao carregar histórico.")}</p>`; // Libera a próxima abertura pra tentar de novo (o painel é lazy).
delete painel.dataset.carregado;
container.innerHTML = `<p class="modal-error">${pidNcfEscapeHtml(
`${(e.message || "Não foi possível carregar o histórico").replace(/\.?$/, ".")} Feche e abra a linha de novo; se persistir, recarregue a página.`
)}</p>`;
} }
} }
@ -861,7 +1030,17 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
// position:fixed não acompanha o scroll sozinho — mais simples fechar o // position:fixed não acompanha o scroll sozinho — mais simples fechar o
// popover do que recalcular a posição a cada evento de scroll/resize. // popover do que recalcular a posição a cada evento de scroll/resize.
window.addEventListener("scroll", () => pidNcfFecharPopoversColuna(), true); // A rolagem dentro do próprio popover (lista de tipos, Resumo longo) não
// fecha: antes fechava e deixava o fim da lista inalcançável.
window.addEventListener(
"scroll",
(evento) => {
const alvo = evento.target;
if (alvo instanceof Element && alvo.closest(".ncf-col-filtro-popover, .ncf-resumo-popover")) return;
pidNcfFecharPopoversColuna();
},
true
);
window.addEventListener("resize", () => pidNcfFecharPopoversColuna()); window.addEventListener("resize", () => pidNcfFecharPopoversColuna());
// ---------------- Resumo copiável (Ações em aberto / Sem análise / Análise sem ação) ---------------- // ---------------- Resumo copiável (Ações em aberto / Sem análise / Análise sem ação) ----------------
@ -878,10 +1057,13 @@ document.addEventListener("DOMContentLoaded", async () => {
function pidNcfDescricaoLinhaAcao(acaoItem) { function pidNcfDescricaoLinhaAcao(acaoItem) {
const data = pidNcfFormatDate(acaoItem.vencimento_efetivo); const data = pidNcfFormatDate(acaoItem.vencimento_efetivo);
const codigo = `${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}`; const codigo = `${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}`;
// finalizada: a data é a da finalização, não a do vencimento
if (acaoItem.status_prazo === "finalizada") {
return { texto: `${codigo} - Finalizada em ${pidNcfFormatDate(acaoItem.data_finalizacao)}`, cor: "normal" };
}
if (acaoItem.status_prazo === "vencida") return { texto: `${codigo} - Vencida em ${data}`, cor: "vencida" }; if (acaoItem.status_prazo === "vencida") return { texto: `${codigo} - Vencida em ${data}`, cor: "vencida" };
if (acaoItem.status_prazo === "vence_7_dias") return { texto: `${codigo} - Vencerá em ${data}`, cor: "vence7" }; if (acaoItem.status_prazo === "vence_7_dias") return { texto: `${codigo} - Vencerá em ${data}`, cor: "vence7" };
if (acaoItem.status_prazo === "sem_vencimento") return { texto: `${codigo} - Sem vencimento definido`, cor: "normal" }; if (acaoItem.status_prazo === "sem_vencimento") return { texto: `${codigo} - Sem vencimento definido`, cor: "normal" };
if (acaoItem.status_prazo === "finalizada") return { texto: `${codigo} - Finalizada em ${data}`, cor: "normal" };
return { texto: `${codigo} - Vencerá em ${data}`, cor: "normal" }; // vence_30_dias / no_prazo return { texto: `${codigo} - Vencerá em ${data}`, cor: "normal" }; // vence_30_dias / no_prazo
} }
@ -1080,11 +1262,39 @@ document.addEventListener("DOMContentLoaded", async () => {
function pidNcfFormatDias(valor) { function pidNcfFormatDias(valor) {
if (valor == null) return null; if (valor == null) return null;
return valor.toString().replace(".", ","); return PID_NCF_DIAS.format(Number(valor));
} }
// Erro de carga do Dashboard: mensagem no lugar dos cards e dos rankings,
// nunca "Nenhum registro no período" (que daria a entender zero itens).
function mostrarErroDashboard() {
dashCards.innerHTML = `<p class="ncf-empty ncf-empty--erro ncf-dash-erro">${pidNcfEscapeHtml(PID_NCF_MSG_ERRO_CARGA)}</p>`;
dashKpis.innerHTML = "";
dashExecucaoCategoria.innerHTML = "";
const linhaErro = (colspan) =>
`<tr><td colspan="${colspan}" class="ncf-ranking-erro">${pidNcfEscapeHtml(PID_NCF_MSG_ERRO_CARGA)}</td></tr>`;
dashMotivosBody.innerHTML = linhaErro(2);
dashClientesBody.innerHTML = linhaErro(5);
dashColaboradoresBody.innerHTML = linhaErro(5);
}
// Nunca rejeita: com o contador, só a carga mais recente (último chip de
// período clicado) escreve na tela; erro vira mensagem. Devolve true/false.
async function carregarDashboard() { async function carregarDashboard() {
const dados = await pidApiRequest(`/nao-conformidades/dashboard/?meses=${encodeURIComponent(periodoAtual)}`); const seq = ++seqDashboard;
let dados;
try {
dados = await pidApiRequest(`/nao-conformidades/dashboard/?meses=${encodeURIComponent(periodoAtual)}`);
} catch (e) {
if (seq === seqDashboard) mostrarErroDashboard();
return seq !== seqDashboard;
}
if (seq !== seqDashboard) return true;
renderDashboard(dados);
return true;
}
function renderDashboard(dados) {
// "Ações abertas" exclui quem já foi finalizado no Sigsistem — uma ação // "Ações abertas" exclui quem já foi finalizado no Sigsistem — uma ação
// concluída há anos não é mais um item de trabalho em aberto, mesmo que // concluída há anos não é mais um item de trabalho em aberto, mesmo que
// o prazo dela (congelado) já tenha passado há muito tempo. // o prazo dela (congelado) já tenha passado há muito tempo.
@ -1246,21 +1456,31 @@ document.addEventListener("DOMContentLoaded", async () => {
dados.append("arquivo_acoes", formAcoes.files[0]); dados.append("arquivo_acoes", formAcoes.files[0]);
formSubmitBtn.disabled = true; formSubmitBtn.disabled = true;
formSubmitBtn.textContent = "Processando..."; formSubmitBtn.textContent = "Processando…";
let importacao;
try { try {
const importacao = await pidApiRequest("/nao-conformidades/importacoes/", { method: "POST", body: dados }); importacao = await pidApiRequest("/nao-conformidades/importacoes/", { method: "POST", body: dados });
} catch (e) {
formError.textContent = e.message || "Não foi possível processar a importação.";
formSubmitBtn.disabled = false;
formSubmitBtn.textContent = "Processar";
return;
}
importForm.hidden = true; importForm.hidden = true;
formOcorrencias.value = ""; formOcorrencias.value = "";
formAcoes.value = ""; formAcoes.value = "";
formOcorrenciasName.textContent = "Nenhum arquivo selecionado"; formOcorrenciasName.textContent = "Nenhum arquivo selecionado";
formAcoesName.textContent = "Nenhum arquivo selecionado"; formAcoesName.textContent = "Nenhum arquivo selecionado";
mostrarResultadoImportacao(importacao); mostrarResultadoImportacao(importacao);
await carregarImportacoes(); // A importação já foi gravada: se só a atualização da tela falhar, isso
// não pode aparecer como falha do upload.
try {
gestaoCarregada = false; gestaoCarregada = false;
await garantirGestaoCarregada(); const resultados = await Promise.all([carregarImportacoes(), garantirGestaoCarregada(), carregarDashboard()]);
await carregarDashboard(); if (!resultados.every(Boolean)) throw new Error("recarga incompleta");
} catch (e) { } catch (e) {
formError.textContent = e.message || "Não foi possível processar a importação."; importResultado.textContent +=
" Importação concluída, mas não foi possível atualizar a tela. Recarregue a página.";
} finally { } finally {
formSubmitBtn.disabled = false; formSubmitBtn.disabled = false;
formSubmitBtn.textContent = "Processar"; formSubmitBtn.textContent = "Processar";
@ -1282,10 +1502,19 @@ document.addEventListener("DOMContentLoaded", async () => {
importResultado.hidden = false; importResultado.hidden = false;
} }
// Nunca rejeita: erro de carga vira mensagem no lugar de "nenhuma
// importação"; devolve true/false (ver importarCarregado).
async function carregarImportacoes() { async function carregarImportacoes() {
const itens = await pidApiRequest("/nao-conformidades/importacoes/"); let itens;
try {
itens = await pidApiRequest("/nao-conformidades/importacoes/");
} catch (e) {
importListBody.innerHTML = ""; importListBody.innerHTML = "";
importListEmpty.hidden = itens.length > 0; pidNcfEstadoVazio(importListEmpty, true, false);
return false;
}
importListBody.innerHTML = "";
pidNcfEstadoVazio(importListEmpty, false, itens.length > 0);
itens.forEach((importacao) => { itens.forEach((importacao) => {
const resumo = importacao.resumo || {}; const resumo = importacao.resumo || {};
const avisos = importacao.avisos || []; const avisos = importacao.avisos || [];
@ -1310,18 +1539,25 @@ document.addEventListener("DOMContentLoaded", async () => {
{ perigoso: true } { perigoso: true }
); );
if (!confirmado) return; if (!confirmado) return;
await pidNcfComBotaoOcupado(excluirBtn, "Excluindo…", async () => {
try { try {
await pidApiRequest(`/nao-conformidades/importacoes/${importacao.id}/`, { method: "DELETE" }); await pidApiRequest(`/nao-conformidades/importacoes/${importacao.id}/`, { method: "DELETE" });
await carregarImportacoes();
} catch (e) { } catch (e) {
await pidAlert(e.message || "Não foi possível excluir."); await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível excluir",
});
return;
} }
await carregarImportacoes();
});
}); });
acaoTd.appendChild(excluirBtn); acaoTd.appendChild(excluirBtn);
importListBody.appendChild(tr); importListBody.appendChild(tr);
}); });
return true;
} }
// Carga inicial — a aba Dashboard já vem visível por padrão. // Carga inicial — a aba Dashboard já vem visível por padrão. Erro vira
// mensagem na tela (carregarDashboard nunca rejeita).
await carregarDashboard(); await carregarDashboard();
}); });

View File

@ -69,9 +69,13 @@ const PID_NOTIF_ICON_EVENT =
const PID_NOTIF_ICON_RESTORE = const PID_NOTIF_ICON_RESTORE =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7L3 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 3v5h5" stroke-linecap="round" stroke-linejoin="round"/></svg>'; '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7L3 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 3v5h5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const PID_NOTIF_DATA_FORMATO = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit" });
/** `iso` é data pura ("AAAA-MM-DD"): monta a Date pelas partes, no fuso local.
* `new Date("AAAA-MM-DD")` seria meia-noite UTC e, no Brasil, cairia no dia anterior. */
function pidFormatNotifDate(iso) { function pidFormatNotifDate(iso) {
const [, month, day] = iso.split("-"); const [ano, mes, dia] = iso.split("-").map(Number);
return `${day}/${month}`; return PID_NOTIF_DATA_FORMATO.format(new Date(ano, mes - 1, dia));
} }
async function pidFetchDismissedNotifIds() { async function pidFetchDismissedNotifIds() {
@ -134,7 +138,21 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!bell || !dropdown || !list) return; if (!bell || !dropdown || !list) return;
const me = await pidGetMe(); const me = await pidGetMe();
const events = me ? await pidFetchEvents() : []; // Cada carga é tratada à parte: uma falha não pode impedir o sino de abrir
// nem esconder o que carregou certo. A falha vira aviso dentro da lista
// (nunca "Nenhuma notificação", que daria a entender que não há nada).
const PID_NOTIF_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
let falhaEventos = false;
let falhaDispensadas = false;
let events = [];
if (me) {
try {
events = (await pidFetchEvents()) || [];
} catch (e) {
falhaEventos = true;
}
}
const eventNotifications = pidEventosElegiveisAgora(events); const eventNotifications = pidEventosElegiveisAgora(events);
const allEventNotifications = events.slice().sort(pidSortEventsByDate).map(pidMapEventToNotification); const allEventNotifications = events.slice().sort(pidSortEventsByDate).map(pidMapEventToNotification);
// Só entra na lista quem o usuário tem permissão de ver — uma notificação // Só entra na lista quem o usuário tem permissão de ver — uma notificação
@ -147,16 +165,30 @@ document.addEventListener("DOMContentLoaded", async () => {
date: pidFormatNotifDate(n.dataIso), date: pidFormatNotifDate(n.dataIso),
})) }))
: []; : [];
let dismissedIds = me ? await pidFetchDismissedNotifIds() : []; let dismissedIds = [];
if (me) {
try {
dismissedIds = await pidFetchDismissedNotifIds();
} catch (e) {
falhaDispensadas = true;
}
}
// Notificação de ferramenta com mais de 10 dias vira dispensada sozinha, // Notificação de ferramenta com mais de 10 dias vira dispensada sozinha,
// sem esperar o usuário clicar no X — daqui em diante ela segue as mesmas // sem esperar o usuário clicar no X — daqui em diante ela segue as mesmas
// regras de qualquer outra notificação dispensada (sai do sino, entra no // regras de qualquer outra notificação dispensada (sai do sino, entra no
// histórico, pode ser restaurada). // histórico, pode ser restaurada). Sem a lista de dispensadas não dá pra
const expiradasAgora = toolNotifications.filter((n) => !dismissedIds.includes(n.id) && pidNotifToolExpirada(n.dataIso)); // saber quais já foram gravadas, então pula. Falha aqui é silenciosa de
// propósito: a notificação já não aparece no sino (ver toolNotificationsAgora),
// só deixa de ir pro histórico até a próxima tentativa.
const expiradasAgora = falhaDispensadas
? []
: toolNotifications.filter((n) => !dismissedIds.includes(n.id) && pidNotifToolExpirada(n.dataIso));
if (expiradasAgora.length) { if (expiradasAgora.length) {
await Promise.all(expiradasAgora.map((n) => pidDismissNotification(n.id))); const resultados = await Promise.allSettled(expiradasAgora.map((n) => pidDismissNotification(n.id)));
dismissedIds.push(...expiradasAgora.map((n) => n.id)); expiradasAgora.forEach((n, idx) => {
if (resultados[idx].status === "fulfilled") dismissedIds.push(n.id);
});
} }
// "Agora" exclui as já expiradas por prazo (mesmo espírito de // "Agora" exclui as já expiradas por prazo (mesmo espírito de
@ -164,10 +196,24 @@ document.addEventListener("DOMContentLoaded", async () => {
// lista completa) — restaurar uma notificação de ferramenta velha pelo // lista completa) — restaurar uma notificação de ferramenta velha pelo
// histórico não a traz de volta pro sino, só mantém o rastro dela lá. // histórico não a traz de volta pro sino, só mantém o rastro dela lá.
const toolNotificationsAgora = toolNotifications.filter((n) => !pidNotifToolExpirada(n.dataIso)); const toolNotificationsAgora = toolNotifications.filter((n) => !pidNotifToolExpirada(n.dataIso));
let notifications = [...eventNotifications, ...toolNotificationsAgora].filter((n) => !dismissedIds.includes(n.id)); // Sem saber o que já foi dispensado, não mostra nada no sino (mostraria de
// volta tudo que o usuário já tinha limpado).
let notifications = falhaDispensadas
? []
: [...eventNotifications, ...toolNotificationsAgora].filter((n) => !dismissedIds.includes(n.id));
function avisoErroCargaHtml() {
return `<li class="notif-empty">${PID_NOTIF_ERRO_CARGA}</li>`;
}
function render() { function render() {
list.innerHTML = notifications.length if (falhaDispensadas) {
list.innerHTML = avisoErroCargaHtml();
if (badge) badge.hidden = true;
if (clearAllBtn) clearAllBtn.disabled = true;
return;
}
const itensHtml = notifications.length
? notifications ? notifications
.map( .map(
(n) => ` (n) => `
@ -183,7 +229,11 @@ document.addEventListener("DOMContentLoaded", async () => {
</li>` </li>`
) )
.join("") .join("")
: falhaEventos
? ""
: `<li class="notif-empty">Nenhuma notificação no momento.</li>`; : `<li class="notif-empty">Nenhuma notificação no momento.</li>`;
// Compromissos não carregaram: o que veio (ferramentas) aparece, mais o aviso.
list.innerHTML = itensHtml + (falhaEventos ? avisoErroCargaHtml() : "");
if (badge) { if (badge) {
badge.hidden = notifications.length === 0; badge.hidden = notifications.length === 0;
@ -200,8 +250,16 @@ document.addEventListener("DOMContentLoaded", async () => {
const dismissBtn = event.target.closest("[data-notif-dismiss]"); const dismissBtn = event.target.closest("[data-notif-dismiss]");
if (!dismissBtn) return; if (!dismissBtn) return;
event.stopPropagation(); event.stopPropagation();
if (dismissBtn.disabled) return;
const id = dismissBtn.getAttribute("data-notif-dismiss"); const id = dismissBtn.getAttribute("data-notif-dismiss");
dismissBtn.disabled = true;
try {
await pidDismissNotification(id); await pidDismissNotification(id);
} catch (e) {
dismissBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível remover a notificação" });
return;
}
dismissedIds.push(id); dismissedIds.push(id);
notifications = notifications.filter((n) => n.id !== id); notifications = notifications.filter((n) => n.id !== id);
render(); render();
@ -209,10 +267,26 @@ document.addEventListener("DOMContentLoaded", async () => {
if (clearAllBtn) { if (clearAllBtn) {
clearAllBtn.addEventListener("click", async () => { clearAllBtn.addEventListener("click", async () => {
await Promise.all(notifications.map((n) => pidDismissNotification(n.id))); if (clearAllBtn.disabled || !notifications.length) return;
dismissedIds.push(...notifications.map((n) => n.id)); const textoOriginal = clearAllBtn.textContent;
notifications = []; clearAllBtn.disabled = true;
clearAllBtn.textContent = "Processando…";
const alvo = notifications.slice();
// allSettled: o que foi dispensado com sucesso sai do sino mesmo que
// outra dispensa falhe; só as que falharam continuam na lista.
const resultados = await Promise.allSettled(alvo.map((n) => pidDismissNotification(n.id)));
const dispensadas = alvo.filter((n, idx) => resultados[idx].status === "fulfilled").map((n) => n.id);
dismissedIds.push(...dispensadas);
notifications = notifications.filter((n) => !dispensadas.includes(n.id));
clearAllBtn.disabled = false;
clearAllBtn.textContent = textoOriginal;
render(); render();
const falha = resultados.find((r) => r.status === "rejected");
if (falha) {
await pidAlert(falha.reason && falha.reason.message ? falha.reason.message : PID_ERRO_GENERICO, {
titulo: "Não foi possível limpar todas as notificações",
});
}
}); });
} }
@ -227,6 +301,10 @@ document.addEventListener("DOMContentLoaded", async () => {
function renderHistory() { function renderHistory() {
if (!historyList) return; if (!historyList) return;
if (falhaDispensadas) {
historyList.innerHTML = avisoErroCargaHtml();
return;
}
const historyItems = [...allEventNotifications, ...toolNotifications].filter((n) => dismissedIds.includes(n.id)); const historyItems = [...allEventNotifications, ...toolNotifications].filter((n) => dismissedIds.includes(n.id));
historyList.innerHTML = historyItems.length historyList.innerHTML = historyItems.length
? historyItems ? historyItems
@ -244,7 +322,10 @@ document.addEventListener("DOMContentLoaded", async () => {
</li>` </li>`
) )
.join("") .join("")
: falhaEventos
? ""
: `<li class="notif-empty">Nenhuma notificação no histórico.</li>`; : `<li class="notif-empty">Nenhuma notificação no histórico.</li>`;
if (falhaEventos) historyList.insertAdjacentHTML("beforeend", avisoErroCargaHtml());
} }
if (historyBtn && historyModal) { if (historyBtn && historyModal) {
@ -265,9 +346,16 @@ document.addEventListener("DOMContentLoaded", async () => {
if (historyList) { if (historyList) {
historyList.addEventListener("click", async (event) => { historyList.addEventListener("click", async (event) => {
const restoreBtn = event.target.closest("[data-notif-restore]"); const restoreBtn = event.target.closest("[data-notif-restore]");
if (!restoreBtn) return; if (!restoreBtn || restoreBtn.disabled) return;
const id = restoreBtn.getAttribute("data-notif-restore"); const id = restoreBtn.getAttribute("data-notif-restore");
restoreBtn.disabled = true;
try {
await pidRestoreNotification(id); await pidRestoreNotification(id);
} catch (e) {
restoreBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível restaurar a notificação" });
return;
}
dismissedIds = dismissedIds.filter((dismissedId) => dismissedId !== id); dismissedIds = dismissedIds.filter((dismissedId) => dismissedId !== id);
notifications = [...eventNotifications, ...toolNotificationsAgora].filter((n) => !dismissedIds.includes(n.id)); notifications = [...eventNotifications, ...toolNotificationsAgora].filter((n) => !dismissedIds.includes(n.id));
renderHistory(); renderHistory();

View File

@ -125,11 +125,26 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
const catalogo = await pidFetchCatalogo(); const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
// Sem catálogo/perfis a tela não tem o que mostrar: avisa na própria
// tabela em vez de deixá-la vazia como se não houvesse perfil nenhum.
let catalogo;
let perfisApi;
try {
[catalogo, perfisApi] = await Promise.all([pidFetchCatalogo(), pidFetchPerfis()]);
} catch (e) {
const body = document.getElementById("pa-table-body");
if (body) body.innerHTML = `<tr class="pa-empty-row"><td colspan="3">${msgErroCarga}</td></tr>`;
return;
}
PID_MODULES = catalogo.modulos; PID_MODULES = catalogo.modulos;
PID_MODULE_APPS = catalogo.aplicacoes_por_modulo; PID_MODULE_APPS = catalogo.aplicacoes_por_modulo;
let profiles = (await pidFetchPerfis()).map(pidPerfilFromApi); let profiles = perfisApi.map(pidPerfilFromApi);
// Retrato do nome + permissões na abertura da edição, pra só perguntar
// "Sair sem salvar" quando algo mudou de fato.
let retratoEdicao = null;
let draftProfile = null; let draftProfile = null;
let activeModuleKey = PID_MODULES[0].key; let activeModuleKey = PID_MODULES[0].key;
let activeTab = "permissoes"; let activeTab = "permissoes";
@ -147,6 +162,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const formCodigo = document.getElementById("pa-codigo"); const formCodigo = document.getElementById("pa-codigo");
const formNome = document.getElementById("pa-nome"); const formNome = document.getElementById("pa-nome");
const formError = document.getElementById("pa-nome-error"); const formError = document.getElementById("pa-nome-error");
const tabUsuarios = document.getElementById("pa-tab-usuarios");
const tabUsuariosHint = document.getElementById("pa-tab-usuarios-hint");
const moduleListEl = document.getElementById("pa-module-list"); const moduleListEl = document.getElementById("pa-module-list");
const treePanel = document.getElementById("pa-tree-panel"); const treePanel = document.getElementById("pa-tree-panel");
const treeSearchInput = document.getElementById("pa-tree-search-input"); const treeSearchInput = document.getElementById("pa-tree-search-input");
@ -202,7 +219,7 @@ document.addEventListener("DOMContentLoaded", async () => {
.map( .map(
(p) => ` (p) => `
<tr> <tr>
<td>${p.codigo}</td> <td class="pa-num">${p.codigo}</td>
<td> <td>
<a href="#" class="pa-name-link" data-pa-edit="${p.codigo}">${pidEscapeHtml(p.nome)}</a> <a href="#" class="pa-name-link" data-pa-edit="${p.codigo}">${pidEscapeHtml(p.nome)}</a>
${p.gerenciaPermissoes ? '<span class="badge-total-access">Acesso total</span>' : ""} ${p.gerenciaPermissoes ? '<span class="badge-total-access">Acesso total</span>' : ""}
@ -374,7 +391,17 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
async function refreshUsuarios() { async function refreshUsuarios() {
try {
usuariosCacheAtual = await pidFetchUsuarios(); usuariosCacheAtual = await pidFetchUsuarios();
} catch (e) {
// Erro de carga não pode parecer "nenhum usuário": avisa nos dois painéis.
["available", "linked"].forEach((kind) => {
usersPanelsEl[kind].body.innerHTML = `<tr class="pa-empty-row"><td colspan="3">${msgErroCarga}</td></tr>`;
usersPanelsEl[kind].footer.textContent = "";
usersPanelsEl[kind].linkBtn.disabled = true;
});
return;
}
renderUsersFromCache(); renderUsersFromCache();
} }
@ -434,7 +461,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const rows = profiles const rows = profiles
.filter((p) => !term || p.nome.toLowerCase().includes(term)) .filter((p) => !term || p.nome.toLowerCase().includes(term))
.slice() .slice()
.sort((a, b) => a.nome.localeCompare(b.nome)); .sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
appManageTbody.innerHTML = rows.length appManageTbody.innerHTML = rows.length
? rows ? rows
@ -507,27 +534,54 @@ document.addEventListener("DOMContentLoaded", async () => {
formCodigo.value = draftProfile.codigo === null ? "Gerado ao salvar" : draftProfile.codigo; formCodigo.value = draftProfile.codigo === null ? "Gerado ao salvar" : draftProfile.codigo;
formNome.value = draftProfile.nome; formNome.value = draftProfile.nome;
// Perfil novo ainda não tem código: não há o que vincular até salvar.
const perfilNovo = draftProfile.codigo === null;
tabUsuarios.disabled = perfilNovo;
tabUsuariosHint.hidden = !perfilNovo;
renderTabs(); renderTabs();
renderModuleList(); renderModuleList();
renderTree(); renderTree();
retratoEdicao = retratoAtual();
listView.hidden = true; listView.hidden = true;
editView.hidden = false; editView.hidden = false;
await renderUsers(); if (!perfilNovo) await renderUsers();
}
function retratoAtual() {
return JSON.stringify({ nome: formNome.value.trim(), permissions: draftProfile.permissions });
}
// Os vínculos de usuário são gravados na hora (não fazem parte do
// rascunho), por isso só nome e permissões entram na comparação.
async function podeSairDaEdicao() {
if (!draftProfile || retratoAtual() === retratoEdicao) return true;
return pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
} }
function closeEdit() { function closeEdit() {
editView.hidden = true; editView.hidden = true;
listView.hidden = false; listView.hidden = false;
retratoEdicao = null;
renderList(); renderList();
} }
async function tentarFecharEdicao() {
if (await podeSairDaEdicao()) closeEdit();
}
listSearchInput.addEventListener("input", renderList); listSearchInput.addEventListener("input", renderList);
newBtn.addEventListener("click", () => openEdit(null)); newBtn.addEventListener("click", () => openEdit(null));
backBtn.addEventListener("click", closeEdit); backBtn.addEventListener("click", tentarFecharEdicao);
cancelBtn.addEventListener("click", closeEdit); cancelBtn.addEventListener("click", tentarFecharEdicao);
appSearchBtn.addEventListener("click", openAppSearchModal); appSearchBtn.addEventListener("click", openAppSearchModal);
appSearchCloseBtn.addEventListener("click", closeAppSearchModal); appSearchCloseBtn.addEventListener("click", closeAppSearchModal);
@ -586,9 +640,15 @@ document.addEventListener("DOMContentLoaded", async () => {
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 && (await pidConfirm(`Excluir o perfil "${profile.nome}"?`, { perigoso: true }))) { if (profile && (await pidConfirm(`Excluir o perfil "${profile.nome}"?`, { perigoso: true }))) {
deleteTarget.disabled = true;
try {
await pidDeletePerfil(codigo); await pidDeletePerfil(codigo);
profiles = (await pidFetchPerfis()).map(pidPerfilFromApi); profiles = (await pidFetchPerfis()).map(pidPerfilFromApi);
renderList(); renderList();
} catch (e) {
deleteTarget.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível excluir o perfil" });
}
} }
} }
}); });
@ -669,9 +729,10 @@ document.addEventListener("DOMContentLoaded", async () => {
async function bulkAlterarVinculo(kind, vincular) { async function bulkAlterarVinculo(kind, vincular) {
const state = usersDualState[kind]; const state = usersDualState[kind];
const ids = Array.from(state.selected); const ids = Array.from(state.selected);
if (!ids.length) return; if (!ids.length || draftProfile.codigo === null) return;
const el = usersPanelsEl[kind]; const el = usersPanelsEl[kind];
el.linkBtn.disabled = true; el.linkBtn.disabled = true;
let erro = null;
try { try {
await Promise.all( await Promise.all(
ids.map((id) => { ids.map((id) => {
@ -682,10 +743,16 @@ document.addEventListener("DOMContentLoaded", async () => {
return pidSetUsuarioPerfis(id, codes); return pidSetUsuarioPerfis(id, codes);
}) })
); );
} catch (e) {
erro = e;
} finally { } finally {
// Recarrega mesmo na falha: parte do lote pode ter sido gravada.
state.selected.clear(); state.selected.clear();
await refreshUsuarios(); await refreshUsuarios();
} }
if (erro) {
await pidAlert(erro.message, { titulo: vincular ? "Não foi possível vincular" : "Não foi possível desvincular" });
}
} }
["available", "linked"].forEach((kind) => { ["available", "linked"].forEach((kind) => {
@ -720,11 +787,16 @@ document.addEventListener("DOMContentLoaded", async () => {
const nome = formNome.value.trim(); const nome = formNome.value.trim();
if (!nome) { if (!nome) {
formError.textContent = "Informe o nome do perfil."; formError.textContent = "Informe o nome do perfil.";
formNome.focus();
return; return;
} }
formError.textContent = ""; formError.textContent = "";
draftProfile.nome = nome; draftProfile.nome = nome;
// Trava contra clique duplo (dois cliques criavam dois perfis).
const textoOriginal = saveBtn.textContent;
saveBtn.disabled = true;
saveBtn.textContent = "Salvando…";
try { try {
if (draftProfile.codigo === null) { if (draftProfile.codigo === null) {
await pidCreatePerfil(pidPerfilToApi(draftProfile)); await pidCreatePerfil(pidPerfilToApi(draftProfile));
@ -734,7 +806,12 @@ document.addEventListener("DOMContentLoaded", async () => {
profiles = (await pidFetchPerfis()).map(pidPerfilFromApi); profiles = (await pidFetchPerfis()).map(pidPerfilFromApi);
closeEdit(); closeEdit();
} catch (e) { } catch (e) {
// O erro aparece abaixo do Nome (ex.: nome repetido); leva o cursor até lá.
formError.textContent = e.message; formError.textContent = e.message;
formNome.focus();
} finally {
saveBtn.disabled = false;
saveBtn.textContent = textoOriginal;
} }
}); });

View File

@ -67,13 +67,12 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!term) return true; if (!term) return true;
return [r.nome_exibicao, r.departamento_exibicao, r.numero_exibicao].some((v) => (v || "").toLowerCase().includes(term)); return [r.nome_exibicao, r.departamento_exibicao, r.numero_exibicao].some((v) => (v || "").toLowerCase().includes(term));
}); });
rows.sort((a, b) => { // Ordem do português (acentos) e numérica nos ramais ("92" antes de "150").
const av = (a[field] || "").toString().toLowerCase(); rows.sort(
const bv = (b[field] || "").toString().toLowerCase(); (a, b) =>
if (av < bv) return -1 * sortDir; (a[field] || "").toString().localeCompare((b[field] || "").toString(), "pt-BR", { numeric: true, sensitivity: "base" }) *
if (av > bv) return 1 * sortDir; sortDir
return 0; );
});
return rows; return rows;
} }
@ -98,6 +97,8 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function render() { function render() {
// Ainda sem dados (carregando ou falhou): busca/ordenação não têm o que mostrar.
if (!ramais) return;
sortHeaders.forEach((th) => { sortHeaders.forEach((th) => {
th.classList.toggle("is-sort-asc", th.dataset.sort === sortKey && sortDir === 1); th.classList.toggle("is-sort-asc", th.dataset.sort === sortKey && sortDir === 1);
th.classList.toggle("is-sort-desc", th.dataset.sort === sortKey && sortDir === -1); th.classList.toggle("is-sort-desc", th.dataset.sort === sortKey && sortDir === -1);
@ -117,7 +118,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr> <tr>
<td>${pidEscapeHtml(r.nome_exibicao)}</td> <td>${pidEscapeHtml(r.nome_exibicao)}</td>
<td>${pidEscapeHtml(r.departamento_exibicao || "—")}</td> <td>${pidEscapeHtml(r.departamento_exibicao || "—")}</td>
<td>${pidEscapeHtml(r.numero_exibicao || "—")}</td> <td class="ram-lookup-num">${pidEscapeHtml(r.numero_exibicao || "—")}</td>
</tr>` </tr>`
) )
.join("") .join("")
@ -132,7 +133,7 @@ document.addEventListener("DOMContentLoaded", async () => {
async function openModal() { async function openModal() {
modal.hidden = false; modal.hidden = false;
if (!ramais) { if (!ramais) {
tableBody.innerHTML = `<tr class="ram-lookup-empty-row"><td colspan="3">Carregando...</td></tr>`; tableBody.innerHTML = `<tr class="ram-lookup-empty-row"><td colspan="3">Carregando…</td></tr>`;
try { try {
const [ramaisResp, departamentosResp] = await Promise.all([ const [ramaisResp, departamentosResp] = await Promise.all([
pidApiRequest("/ramais/"), pidApiRequest("/ramais/"),
@ -141,8 +142,12 @@ document.addEventListener("DOMContentLoaded", async () => {
ramais = ramaisResp || []; ramais = ramaisResp || [];
departamentos = departamentosResp || []; departamentos = departamentosResp || [];
} catch (e) { } catch (e) {
ramais = []; // Falha não vira "Nenhum ramal encontrado." nem fica em cache:
departamentos = []; // `ramais` continua null e a próxima abertura tenta de novo.
tableBody.innerHTML = `<tr class="ram-lookup-empty-row"><td colspan="3">Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.</td></tr>`;
countEl.textContent = "";
paginationEl.innerHTML = "";
return;
} }
renderDeptoFilters(); renderDeptoFilters();
} }

View File

@ -223,6 +223,37 @@ document.addEventListener("DOMContentLoaded", async () => {
const chamadoIframe = document.getElementById("ram-chamado-iframe"); const chamadoIframe = document.getElementById("ram-chamado-iframe");
const chamadoCloseBtn = document.getElementById("ram-chamado-close-btn"); const chamadoCloseBtn = document.getElementById("ram-chamado-close-btn");
const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
// Trava o botão (e troca o texto) enquanto a requisição roda, contra
// clique duplo; restaura no fim, com sucesso ou erro.
async function comTrava(botao, textoOcupado, acao) {
const textoOriginal = botao.textContent;
botao.disabled = true;
if (textoOcupado) botao.textContent = textoOcupado;
try {
return await acao();
} finally {
botao.disabled = false;
if (textoOcupado) botao.textContent = textoOriginal;
}
}
// Retrato dos valores de um formulário na abertura, pra só perguntar
// "Sair sem salvar" quando algo mudou de fato.
function retratoCampos(campos) {
return JSON.stringify(campos.map((campo) => campo.value));
}
async function podeDescartar(retrato, campos) {
if (retrato === null || retratoCampos(campos) === retrato) return true;
return pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
}
let ramais = []; let ramais = [];
let usuariosDisponiveis = []; let usuariosDisponiveis = [];
let viewingAusencia = null; let viewingAusencia = null;
@ -271,7 +302,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr class="${rowClass}"> <tr class="${rowClass}">
<td>${nomeHtml}</td> <td>${nomeHtml}</td>
<td>${pidEscapeHtml(ramal.departamento_exibicao || "—")}</td> <td>${pidEscapeHtml(ramal.departamento_exibicao || "—")}</td>
<td>${ramalHtml}</td> <td class="pa-num">${ramalHtml}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td> <td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`; </tr>`;
} }
@ -285,19 +316,30 @@ document.addEventListener("DOMContentLoaded", async () => {
return (!termNome || nome.includes(termNome)) && (!termDepartamento || departamento.includes(termDepartamento)); return (!termNome || nome.includes(termNome)) && (!termDepartamento || departamento.includes(termDepartamento));
}); });
// Lista vazia por filtro não é o mesmo que lista vazia de verdade.
const vazio = ramais.length ? "Nenhum resultado para o filtro." : "Nenhum ramal cadastrado ainda.";
tableBody.innerHTML = rows.length tableBody.innerHTML = rows.length
? rows.map(rowHtml).join("") ? rows.map(rowHtml).join("")
: `<tr class="pa-empty-row"><td colspan="4">Nenhum ramal cadastrado ainda.</td></tr>`; : `<tr class="pa-empty-row"><td colspan="4">${vazio}</td></tr>`;
if (footerCount) footerCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`; if (footerCount) footerCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`;
} }
// Cada aba só busca o que o usuário pode ver (sem permissão a API devolve
// 403). Falha vira aviso na própria tabela, nunca "nenhum cadastrado".
async function refresh() { async function refresh() {
if (!ramaisCanView) return;
try {
const tasks = [pidFetchRamais()]; const tasks = [pidFetchRamais()];
if (ramaisCanManage) tasks.push(pidFetchUsuariosDisponiveisParaRamal()); if (ramaisCanManage) tasks.push(pidFetchUsuariosDisponiveisParaRamal());
const results = await Promise.all(tasks); const results = await Promise.all(tasks);
ramais = results[0] || []; ramais = results[0] || [];
if (ramaisCanManage) usuariosDisponiveis = results[1] || []; if (ramaisCanManage) usuariosDisponiveis = results[1] || [];
} catch (e) {
tableBody.innerHTML = `<tr class="pa-empty-row"><td colspan="4">${msgErroCarga}</td></tr>`;
if (footerCount) footerCount.textContent = "";
return;
}
render(); render();
} }
@ -318,8 +360,8 @@ document.addEventListener("DOMContentLoaded", async () => {
return ` return `
<tr> <tr>
<td>${pidEscapeHtml(tel.nome)}</td> <td>${pidEscapeHtml(tel.nome)}</td>
<td>${pidEscapeHtml(tel.ramal || "—")}</td> <td class="pa-num">${pidEscapeHtml(tel.ramal || "—")}</td>
<td>${pidEscapeHtml(tel.telefone || "—")}</td> <td class="pa-num">${pidEscapeHtml(tel.telefone || "—")}</td>
<td>${pidEscapeHtml(tel.observacoes || "—")}</td> <td>${pidEscapeHtml(tel.observacoes || "—")}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td> <td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`; </tr>`;
@ -334,18 +376,29 @@ document.addEventListener("DOMContentLoaded", async () => {
return (!termNome || nome.includes(termNome)) && (!termObs || obs.includes(termObs)); return (!termNome || nome.includes(termNome)) && (!termObs || obs.includes(termObs));
}); });
const vazio = telefonesExternos.length ? "Nenhum resultado para o filtro." : "Nenhum telefone externo cadastrado ainda.";
telTableBody.innerHTML = rows.length telTableBody.innerHTML = rows.length
? rows.map(telefoneRowHtml).join("") ? rows.map(telefoneRowHtml).join("")
: `<tr class="pa-empty-row"><td colspan="5">Nenhum telefone externo cadastrado ainda.</td></tr>`; : `<tr class="pa-empty-row"><td colspan="5">${vazio}</td></tr>`;
if (telFooterCount) telFooterCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`; if (telFooterCount) telFooterCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`;
} }
async function refreshTelefonesExternos() { async function refreshTelefonesExternos() {
if (!telCanView || !telTableBody) return;
try {
telefonesExternos = await pidFetchTelefonesExternos(); telefonesExternos = await pidFetchTelefonesExternos();
} catch (e) {
telTableBody.innerHTML = `<tr class="pa-empty-row"><td colspan="5">${msgErroCarga}</td></tr>`;
if (telFooterCount) telFooterCount.textContent = "";
return;
}
renderTelefonesExternos(); renderTelefonesExternos();
} }
const telCampos = [telFormNome, telFormRamal, telFormTelefone, telFormObservacoes];
let telRetrato = null;
function openTelefoneModal(tel) { function openTelefoneModal(tel) {
telFormError.textContent = ""; telFormError.textContent = "";
editingTelefoneId = tel ? tel.id : null; editingTelefoneId = tel ? tel.id : null;
@ -355,12 +408,18 @@ document.addEventListener("DOMContentLoaded", async () => {
telFormRamal.value = tel ? tel.ramal || "" : ""; telFormRamal.value = tel ? tel.ramal || "" : "";
telFormTelefone.value = tel ? tel.telefone || "" : ""; telFormTelefone.value = tel ? tel.telefone || "" : "";
telFormObservacoes.value = tel ? tel.observacoes || "" : ""; telFormObservacoes.value = tel ? tel.observacoes || "" : "";
telRetrato = retratoCampos(telCampos);
telFormModal.hidden = false; telFormModal.hidden = false;
} }
function closeTelefoneModal() { function closeTelefoneModal() {
telFormModal.hidden = true; telFormModal.hidden = true;
editingTelefoneId = null; editingTelefoneId = null;
telRetrato = null;
}
async function tentarFecharTelefoneModal() {
if (await podeDescartar(telRetrato, telCampos)) closeTelefoneModal();
} }
function funcaoRowHtml(fun) { function funcaoRowHtml(fun) {
@ -397,10 +456,20 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
async function refreshFuncoesTelefonia() { async function refreshFuncoesTelefonia() {
if (!funCanView || !funTableBody) return;
try {
funcoesTelefonia = await pidFetchFuncoesTelefonia(); funcoesTelefonia = await pidFetchFuncoesTelefonia();
} catch (e) {
funTableBody.innerHTML = `<tr class="pa-empty-row"><td colspan="4">${msgErroCarga}</td></tr>`;
if (funFooterCount) funFooterCount.textContent = "";
return;
}
renderFuncoesTelefonia(); renderFuncoesTelefonia();
} }
const funCampos = [funFormComando, funFormFuncao, funFormResumo];
let funRetrato = null;
function openFuncaoModal(fun) { function openFuncaoModal(fun) {
funFormError.textContent = ""; funFormError.textContent = "";
editingFuncaoId = fun ? fun.id : null; editingFuncaoId = fun ? fun.id : null;
@ -409,14 +478,23 @@ document.addEventListener("DOMContentLoaded", async () => {
funFormComando.value = fun ? fun.comando || "" : ""; funFormComando.value = fun ? fun.comando || "" : "";
funFormFuncao.value = fun ? fun.funcao || "" : ""; funFormFuncao.value = fun ? fun.funcao || "" : "";
funFormResumo.value = fun ? fun.resumo || "" : ""; funFormResumo.value = fun ? fun.resumo || "" : "";
funRetrato = retratoCampos(funCampos);
funFormModal.hidden = false; funFormModal.hidden = false;
} }
function closeFuncaoModal() { function closeFuncaoModal() {
funFormModal.hidden = true; funFormModal.hidden = true;
editingFuncaoId = null; editingFuncaoId = null;
funRetrato = null;
} }
async function tentarFecharFuncaoModal() {
if (await podeDescartar(funRetrato, funCampos)) closeFuncaoModal();
}
const formCampos = [formNome, formDepartamento, formNumero];
let formRetrato = null;
function openFormModal(ramal) { function openFormModal(ramal) {
formError.textContent = ""; formError.textContent = "";
@ -455,30 +533,52 @@ document.addEventListener("DOMContentLoaded", async () => {
formDepartamento.disabled = false; formDepartamento.disabled = false;
} }
formRetrato = retratoCampos(formCampos);
formModal.hidden = false; formModal.hidden = false;
} }
function closeFormModal() { function closeFormModal() {
formModal.hidden = true; formModal.hidden = true;
editingId = null; editingId = null;
formRetrato = null;
} }
async function tentarFecharFormModal() {
if (await podeDescartar(formRetrato, formCampos)) closeFormModal();
}
const ausCampos = [ausUsuario, ausDataInicio, ausHoraInicio, ausDataFim, ausHoraVolta, ausTipo, ausObservacoes];
let ausRetrato = null;
function openAusenciaModal() { function openAusenciaModal() {
ausError.textContent = ""; ausError.textContent = "";
ausUsuario.innerHTML = usuarioOptionsHtml(); // Opção vazia obriga a escolher: antes o primeiro nome vinha marcado
// e dava para salvar a ausência na pessoa errada sem perceber.
ausUsuario.innerHTML = `<option value="">Selecione…</option>${usuarioOptionsHtml()}`;
ausUsuario.value = "";
ausDataInicio.value = ""; ausDataInicio.value = "";
ausHoraInicio.value = ""; ausHoraInicio.value = "";
ausDataFim.value = ""; ausDataFim.value = "";
ausHoraVolta.value = ""; ausHoraVolta.value = "";
ausTipo.value = "Ausência"; ausTipo.value = "Ausência";
ausObservacoes.value = ""; ausObservacoes.value = "";
ausRetrato = retratoCampos(ausCampos);
ausenciaModal.hidden = false; ausenciaModal.hidden = false;
} }
function closeAusenciaModal() { function closeAusenciaModal() {
ausenciaModal.hidden = true; ausenciaModal.hidden = true;
ausRetrato = null;
} }
async function tentarFecharAusenciaModal() {
if (await podeDescartar(ausRetrato, ausCampos)) closeAusenciaModal();
}
const ausViewCampos = [ausViewDataInicio, ausViewHoraInicio, ausViewDataFim, ausViewHoraVolta, ausViewTipo, ausViewObservacoes];
// Só existe enquanto a ausência está em modo de edição.
let ausViewRetrato = null;
function fillAusenciaViewFields(ausencia, ramal) { function fillAusenciaViewFields(ausencia, ramal) {
ausViewUsuario.innerHTML = `<option>${pidEscapeHtml(ramal.nome_exibicao)} - ${pidEscapeHtml(ramal.departamento_exibicao || "—")} / Ramal: ${pidEscapeHtml(ramal.numero_exibicao || "—")}</option>`; ausViewUsuario.innerHTML = `<option>${pidEscapeHtml(ramal.nome_exibicao)} - ${pidEscapeHtml(ramal.departamento_exibicao || "—")} / Ramal: ${pidEscapeHtml(ramal.numero_exibicao || "—")}</option>`;
ausViewDataInicio.value = ausencia.data_inicio || ""; ausViewDataInicio.value = ausencia.data_inicio || "";
@ -500,6 +600,7 @@ document.addEventListener("DOMContentLoaded", async () => {
ausViewTitle.textContent = editing ? "Editar Ausência" : "Visualizar Ausência"; ausViewTitle.textContent = editing ? "Editar Ausência" : "Visualizar Ausência";
ausViewActions.hidden = editing; ausViewActions.hidden = editing;
ausEditActions.hidden = !editing; ausEditActions.hidden = !editing;
ausViewRetrato = editing ? retratoCampos(ausViewCampos) : null;
} }
async function openAusenciaViewModal(ramal) { async function openAusenciaViewModal(ramal) {
@ -507,7 +608,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) {
await pidAlert(e.message); await pidAlert(e.message, { titulo: "Não foi possível abrir a ausência" });
return; return;
} }
viewingRamalContext = ramal; viewingRamalContext = ramal;
@ -522,6 +623,11 @@ document.addEventListener("DOMContentLoaded", async () => {
ausViewModal.hidden = true; ausViewModal.hidden = true;
viewingAusencia = null; viewingAusencia = null;
viewingRamalContext = null; viewingRamalContext = null;
ausViewRetrato = null;
}
async function tentarFecharAusenciaViewModal() {
if (await podeDescartar(ausViewRetrato, ausViewCampos)) closeAusenciaViewModal();
} }
function openChamadoModal() { function openChamadoModal() {
@ -541,10 +647,10 @@ document.addEventListener("DOMContentLoaded", async () => {
if (telSearchObsInput) telSearchObsInput.addEventListener("input", renderTelefonesExternos); if (telSearchObsInput) telSearchObsInput.addEventListener("input", renderTelefonesExternos);
if (telAddBtn) telAddBtn.addEventListener("click", () => openTelefoneModal(null)); if (telAddBtn) telAddBtn.addEventListener("click", () => openTelefoneModal(null));
if (telFormCancelBtn) telFormCancelBtn.addEventListener("click", closeTelefoneModal); if (telFormCancelBtn) telFormCancelBtn.addEventListener("click", tentarFecharTelefoneModal);
if (telFormModal) { if (telFormModal) {
telFormModal.addEventListener("click", (event) => { telFormModal.addEventListener("click", (event) => {
if (event.target === telFormModal) closeTelefoneModal(); if (event.target === telFormModal) tentarFecharTelefoneModal();
}); });
} }
@ -553,6 +659,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const nome = telFormNome.value.trim(); const nome = telFormNome.value.trim();
if (!nome) { if (!nome) {
telFormError.textContent = "Informe o nome."; telFormError.textContent = "Informe o nome.";
telFormNome.focus();
return; return;
} }
telFormError.textContent = ""; telFormError.textContent = "";
@ -564,12 +671,15 @@ document.addEventListener("DOMContentLoaded", async () => {
observacoes: telFormObservacoes.value.trim(), observacoes: telFormObservacoes.value.trim(),
}; };
const ocupado = editingTelefoneId ? "Salvando…" : "Adicionando…";
try { try {
await comTrava(telFormSaveBtn, ocupado, async () => {
if (editingTelefoneId) { if (editingTelefoneId) {
await pidUpdateTelefoneExterno(editingTelefoneId, payload); await pidUpdateTelefoneExterno(editingTelefoneId, payload);
} else { } else {
await pidCreateTelefoneExterno(payload); await pidCreateTelefoneExterno(payload);
} }
});
closeTelefoneModal(); closeTelefoneModal();
await refreshTelefonesExternos(); await refreshTelefonesExternos();
} catch (e) { } catch (e) {
@ -593,7 +703,12 @@ document.addEventListener("DOMContentLoaded", async () => {
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 && (await pidConfirm(`Remover o telefone de "${tel.nome}"?`, { perigoso: true }))) { if (tel && (await pidConfirm(`Remover o telefone de "${tel.nome}"?`, { perigoso: true }))) {
await pidDeleteTelefoneExterno(id); try {
await comTrava(deleteBtn, null, () => pidDeleteTelefoneExterno(id));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível remover o telefone" });
return;
}
await refreshTelefonesExternos(); await refreshTelefonesExternos();
} }
} }
@ -601,10 +716,10 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
if (funAddBtn) funAddBtn.addEventListener("click", () => openFuncaoModal(null)); if (funAddBtn) funAddBtn.addEventListener("click", () => openFuncaoModal(null));
if (funFormCancelBtn) funFormCancelBtn.addEventListener("click", closeFuncaoModal); if (funFormCancelBtn) funFormCancelBtn.addEventListener("click", tentarFecharFuncaoModal);
if (funFormModal) { if (funFormModal) {
funFormModal.addEventListener("click", (event) => { funFormModal.addEventListener("click", (event) => {
if (event.target === funFormModal) closeFuncaoModal(); if (event.target === funFormModal) tentarFecharFuncaoModal();
}); });
} }
@ -613,6 +728,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const comando = funFormComando.value.trim(); const comando = funFormComando.value.trim();
if (!comando) { if (!comando) {
funFormError.textContent = "Informe o comando."; funFormError.textContent = "Informe o comando.";
funFormComando.focus();
return; return;
} }
funFormError.textContent = ""; funFormError.textContent = "";
@ -623,12 +739,15 @@ document.addEventListener("DOMContentLoaded", async () => {
resumo: funFormResumo.value.trim(), resumo: funFormResumo.value.trim(),
}; };
const ocupado = editingFuncaoId ? "Salvando…" : "Adicionando…";
try { try {
await comTrava(funFormSaveBtn, ocupado, async () => {
if (editingFuncaoId) { if (editingFuncaoId) {
await pidUpdateFuncaoTelefonia(editingFuncaoId, payload); await pidUpdateFuncaoTelefonia(editingFuncaoId, payload);
} else { } else {
await pidCreateFuncaoTelefonia(payload); await pidCreateFuncaoTelefonia(payload);
} }
});
closeFuncaoModal(); closeFuncaoModal();
await refreshFuncoesTelefonia(); await refreshFuncoesTelefonia();
} catch (e) { } catch (e) {
@ -652,7 +771,12 @@ document.addEventListener("DOMContentLoaded", async () => {
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 && (await pidConfirm(`Remover a função "${fun.comando}"?`, { perigoso: true }))) { if (fun && (await pidConfirm(`Remover a função "${fun.comando}"?`, { perigoso: true }))) {
await pidDeleteFuncaoTelefonia(id); try {
await comTrava(deleteBtn, null, () => pidDeleteFuncaoTelefonia(id));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível remover a função" });
return;
}
await refreshFuncoesTelefonia(); await refreshFuncoesTelefonia();
} }
} }
@ -660,10 +784,10 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
if (addBtn) addBtn.addEventListener("click", () => openFormModal(null)); if (addBtn) addBtn.addEventListener("click", () => openFormModal(null));
if (formCancelBtn) formCancelBtn.addEventListener("click", closeFormModal); if (formCancelBtn) formCancelBtn.addEventListener("click", tentarFecharFormModal);
if (formModal) { if (formModal) {
formModal.addEventListener("click", (event) => { formModal.addEventListener("click", (event) => {
if (event.target === formModal) closeFormModal(); if (event.target === formModal) tentarFecharFormModal();
}); });
} }
@ -675,11 +799,14 @@ document.addEventListener("DOMContentLoaded", async () => {
if (editingMode !== "usuario" && !nome) { if (editingMode !== "usuario" && !nome) {
formError.textContent = "Informe o nome."; formError.textContent = "Informe o nome.";
formNome.focus();
return; return;
} }
formError.textContent = ""; formError.textContent = "";
const ocupado = editingMode === "create" ? "Adicionando…" : "Salvando…";
try { try {
await comTrava(formSaveBtn, ocupado, async () => {
if (editingMode === "usuario") { if (editingMode === "usuario") {
await pidUpdateRamalUsuario(editingId, numero); await pidUpdateRamalUsuario(editingId, numero);
} else if (editingMode === "avulso") { } else if (editingMode === "avulso") {
@ -687,6 +814,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} else { } else {
await pidCreateRamal({ nome, departamento, numero }); await pidCreateRamal({ nome, departamento, numero });
} }
});
closeFormModal(); closeFormModal();
await refresh(); await refresh();
} catch (e) { } catch (e) {
@ -696,10 +824,10 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
if (ausenciaBtn) ausenciaBtn.addEventListener("click", openAusenciaModal); if (ausenciaBtn) ausenciaBtn.addEventListener("click", openAusenciaModal);
if (ausCancelBtn) ausCancelBtn.addEventListener("click", closeAusenciaModal); if (ausCancelBtn) ausCancelBtn.addEventListener("click", tentarFecharAusenciaModal);
if (ausenciaModal) { if (ausenciaModal) {
ausenciaModal.addEventListener("click", (event) => { ausenciaModal.addEventListener("click", (event) => {
if (event.target === ausenciaModal) closeAusenciaModal(); if (event.target === ausenciaModal) tentarFecharAusenciaModal();
}); });
} }
@ -709,8 +837,20 @@ document.addEventListener("DOMContentLoaded", async () => {
const dataInicio = ausDataInicio.value; const dataInicio = ausDataInicio.value;
const dataFim = ausDataFim.value; const dataFim = ausDataFim.value;
if (!usuarioId || !dataInicio || !dataFim) { // Mensagem e cursor no primeiro campo que falta.
ausError.textContent = "Selecione o usuário e as datas inicial e final."; if (!usuarioId) {
ausError.textContent = "Selecione o usuário.";
ausUsuario.focus();
return;
}
if (!dataInicio) {
ausError.textContent = "Informe a data inicial.";
ausDataInicio.focus();
return;
}
if (!dataFim) {
ausError.textContent = "Informe a data final.";
ausDataFim.focus();
return; return;
} }
ausError.textContent = ""; ausError.textContent = "";
@ -726,7 +866,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}; };
try { try {
await pidCreateRamalAusencia(payload); await comTrava(ausSaveBtn, "Salvando…", () => pidCreateRamalAusencia(payload));
closeAusenciaModal(); closeAusenciaModal();
await refresh(); await refresh();
} catch (e) { } catch (e) {
@ -735,17 +875,18 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
if (ausViewCloseBtn) ausViewCloseBtn.addEventListener("click", closeAusenciaViewModal); if (ausViewCloseBtn) ausViewCloseBtn.addEventListener("click", tentarFecharAusenciaViewModal);
if (ausViewModal) { if (ausViewModal) {
ausViewModal.addEventListener("click", (event) => { ausViewModal.addEventListener("click", (event) => {
if (event.target === ausViewModal) closeAusenciaViewModal(); if (event.target === ausViewModal) tentarFecharAusenciaViewModal();
}); });
} }
if (ausViewEditBtn) ausViewEditBtn.addEventListener("click", () => setAusenciaViewMode("edit")); if (ausViewEditBtn) ausViewEditBtn.addEventListener("click", () => setAusenciaViewMode("edit"));
if (ausViewCancelEditBtn) { if (ausViewCancelEditBtn) {
ausViewCancelEditBtn.addEventListener("click", () => { ausViewCancelEditBtn.addEventListener("click", async () => {
if (!(await podeDescartar(ausViewRetrato, ausViewCampos))) return;
fillAusenciaViewFields(viewingAusencia, viewingRamalContext); fillAusenciaViewFields(viewingAusencia, viewingRamalContext);
setAusenciaViewMode("view"); setAusenciaViewMode("view");
}); });
@ -757,6 +898,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const dataFim = ausViewDataFim.value; const dataFim = ausViewDataFim.value;
if (!dataInicio || !dataFim) { if (!dataInicio || !dataFim) {
ausViewError.textContent = "Informe as datas inicial e final."; ausViewError.textContent = "Informe as datas inicial e final.";
(dataInicio ? ausViewDataFim : ausViewDataInicio).focus();
return; return;
} }
ausViewError.textContent = ""; ausViewError.textContent = "";
@ -771,7 +913,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}; };
try { try {
await pidUpdateRamalAusencia(viewingAusencia.id, payload); await comTrava(ausViewSaveBtn, "Salvando…", () => pidUpdateRamalAusencia(viewingAusencia.id, payload));
closeAusenciaViewModal(); closeAusenciaViewModal();
await refresh(); await refresh();
} catch (e) { } catch (e) {
@ -784,11 +926,11 @@ document.addEventListener("DOMContentLoaded", async () => {
ausViewDeleteBtn.addEventListener("click", async () => { ausViewDeleteBtn.addEventListener("click", async () => {
if (!(await pidConfirm("Deletar esta ausência?", { perigoso: true }))) return; if (!(await pidConfirm("Deletar esta ausência?", { perigoso: true }))) return;
try { try {
await pidDeleteRamalAusencia(viewingAusencia.id); await comTrava(ausViewDeleteBtn, null, () => pidDeleteRamalAusencia(viewingAusencia.id));
closeAusenciaViewModal(); closeAusenciaViewModal();
await refresh(); await refresh();
} catch (e) { } catch (e) {
await pidAlert(e.message); await pidAlert(e.message, { titulo: "Não foi possível deletar a ausência" });
} }
}); });
} }
@ -815,7 +957,12 @@ document.addEventListener("DOMContentLoaded", async () => {
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 && (await pidConfirm(`Remover o ramal de "${ramal.nome_exibicao}"?`, { perigoso: true }))) { if (ramal && (await pidConfirm(`Remover o ramal de "${ramal.nome_exibicao}"?`, { perigoso: true }))) {
await pidDeleteRamal(avulsoId); try {
await comTrava(deleteBtn, null, () => pidDeleteRamal(avulsoId));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível remover o ramal" });
return;
}
await refresh(); await refresh();
} }
return; return;

View File

@ -10,15 +10,33 @@ const PID_COLOR_THEMES = [
{ key: "vermelho", label: "Vermelho", swatch: "#ef4444" }, { key: "vermelho", label: "Vermelho", swatch: "#ef4444" },
]; ];
// localStorage pode estar bloqueado (janela privada, política do navegador) e
// aí lança: o tema vira só da sessão atual, mas o menu da conta continua funcionando.
function pidLerPreferencia(chave) {
try {
return localStorage.getItem(chave);
} catch (e) {
return null;
}
}
function pidGravarPreferencia(chave, valor) {
try {
localStorage.setItem(chave, valor);
} catch (e) {
// sem armazenamento: a escolha vale só até recarregar a página
}
}
(function applyStoredTheme() { (function applyStoredTheme() {
const stored = localStorage.getItem(PID_THEME_KEY); const stored = pidLerPreferencia(PID_THEME_KEY);
if (stored) { if (stored) {
document.documentElement.setAttribute("data-theme", stored); document.documentElement.setAttribute("data-theme", stored);
} }
})(); })();
(function applyStoredColorTheme() { (function applyStoredColorTheme() {
const stored = localStorage.getItem(PID_COLOR_THEME_KEY); const stored = pidLerPreferencia(PID_COLOR_THEME_KEY);
if (stored) { if (stored) {
document.documentElement.setAttribute("data-color-theme", stored); document.documentElement.setAttribute("data-color-theme", stored);
} }
@ -28,7 +46,7 @@ function pidToggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "dark"; const current = document.documentElement.getAttribute("data-theme") || "dark";
const next = current === "dark" ? "light" : "dark"; const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next); document.documentElement.setAttribute("data-theme", next);
localStorage.setItem(PID_THEME_KEY, next); pidGravarPreferencia(PID_THEME_KEY, next);
document.dispatchEvent(new CustomEvent("pid:theme-changed", { detail: { theme: next } })); document.dispatchEvent(new CustomEvent("pid:theme-changed", { detail: { theme: next } }));
} }
@ -38,7 +56,7 @@ function pidGetColorTheme() {
function pidSetColorTheme(themeKey) { function pidSetColorTheme(themeKey) {
document.documentElement.setAttribute("data-color-theme", themeKey); document.documentElement.setAttribute("data-color-theme", themeKey);
localStorage.setItem(PID_COLOR_THEME_KEY, themeKey); pidGravarPreferencia(PID_COLOR_THEME_KEY, themeKey);
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {

View File

@ -22,10 +22,22 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
let users = await pidFetchUsuarios(); const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
let editingId = null;
const tableBody = document.getElementById("ua-table-body"); const tableBody = document.getElementById("ua-table-body");
// Falha de carga não pode virar "Nenhum usuário encontrado.".
let users;
try {
users = await pidFetchUsuarios();
} catch (e) {
tableBody.innerHTML = `<tr class="pa-empty-row"><td colspan="10">${msgErroCarga}</td></tr>`;
return;
}
let editingId = null;
// Retrato dos campos na abertura da edição, pra só perguntar "Sair sem
// salvar" quando algo mudou de fato.
let retratoEdicao = null;
const footerCount = document.getElementById("ua-footer-count"); const footerCount = document.getElementById("ua-footer-count");
const searchInput = document.getElementById("ua-search-input"); const searchInput = document.getElementById("ua-search-input");
const statusFiltros = document.getElementById("ua-status-filtros"); const statusFiltros = document.getElementById("ua-status-filtros");
@ -145,11 +157,11 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr> <tr>
<td>${pidEscapeHtml(u.username)}</td> <td>${pidEscapeHtml(u.username)}</td>
<td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${pidEscapeHtml(u.nome)}</a></td> <td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${pidEscapeHtml(u.nome)}</a></td>
<td>${celulaCodigo(u.codigo_folha)}</td> <td class="pa-num">${celulaCodigo(u.codigo_folha)}</td>
<td>${celulaCodigo(u.codigo_questor)}</td> <td class="pa-num">${celulaCodigo(u.codigo_questor)}</td>
<td>${celulaCodigo(u.codigo_tareffa)}</td> <td class="pa-num">${celulaCodigo(u.codigo_tareffa)}</td>
<td>${celulaCodigo(u.codigo_contabit)}</td> <td class="pa-num">${celulaCodigo(u.codigo_contabit)}</td>
<td>${celulaCodigo(u.ramal)}</td> <td class="pa-num">${celulaCodigo(u.ramal)}</td>
<td>${pidEscapeHtml(profileNomes(u.perfis))}</td> <td>${pidEscapeHtml(profileNomes(u.perfis))}</td>
<td><span class="status-pill ${u.is_active ? "status-pill--ativo" : "status-pill--inativo"}">${u.is_active ? "Ativo" : "Inativo"}</span></td> <td><span class="status-pill ${u.is_active ? "status-pill--ativo" : "status-pill--inativo"}">${u.is_active ? "Ativo" : "Inativo"}</span></td>
<td> <td>
@ -174,7 +186,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
async function fillProfileChecklist(selectedCodigos) { async function fillProfileChecklist(selectedCodigos) {
const perfis = (await pidFetchPerfis()).slice().sort((a, b) => a.nome.localeCompare(b.nome)); const perfis = (await pidFetchPerfis()).slice().sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
profileChecklist.innerHTML = perfis profileChecklist.innerHTML = perfis
.map( .map(
(p) => ` (p) => `
@ -193,7 +205,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const candidatos = users const candidatos = users
.filter((u) => u.id !== excludeId && u.is_active) .filter((u) => u.id !== excludeId && u.is_active)
.slice() .slice()
.sort((a, b) => a.nome.localeCompare(b.nome)); .sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
seletorLiderados.setDados(candidatos, selectedIds); seletorLiderados.setDados(candidatos, selectedIds);
} }
@ -204,7 +216,7 @@ document.addEventListener("DOMContentLoaded", async () => {
let departamentosCache = []; let departamentosCache = [];
async function fillDepartamentoChecklist(selectedIds) { async function fillDepartamentoChecklist(selectedIds) {
departamentosCache = (await pidFetchDepartamentos()).slice().sort((a, b) => a.nome.localeCompare(b.nome)); departamentosCache = (await pidFetchDepartamentos()).slice().sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
departamentoChecklist.innerHTML = departamentosCache departamentoChecklist.innerHTML = departamentosCache
.map( .map(
(d) => ` (d) => `
@ -232,7 +244,57 @@ document.addEventListener("DOMContentLoaded", async () => {
); );
} }
// Valores do formulário no formato do payload; usado no salvar e no
// retrato de "houve alteração?".
function coletarFormulario() {
return {
username: loginInput.value.trim(),
nome: nomeInput.value.trim(),
senha: senhaInput.value,
perfis: Array.from(profileChecklist.querySelectorAll('input[type="checkbox"]:checked')).map((cb) => Number(cb.value)),
departamentos: selectedDepartamentoIds(),
email: emailInput.value.trim(),
codigo_folha: codigoFolhaInput.value.trim(),
codigo_questor: codigoQuestorInput.value.trim(),
codigo_tareffa: codigoTareffaInput.value.trim(),
codigo_contabit: codigoContabitInput.value.trim(),
ramal: ramalInput.value.trim(),
data_aniversario: dataAniversarioInput.value || null,
lideranca: liderancaInput.checked,
liderados: selectedLideradosIds(),
is_active: ativoInput.checked,
};
}
function retratoAtual() {
const dados = coletarFormulario();
// Ordem de marcação não importa, só o conjunto.
["perfis", "departamentos", "liderados"].forEach((k) => (dados[k] = dados[k].slice().sort((a, b) => a - b)));
return JSON.stringify(dados);
}
async function podeSairDaEdicao() {
if (retratoEdicao === null || retratoAtual() === retratoEdicao) return true;
return pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
}
async function openEdit(id) { async function openEdit(id) {
try {
await preencherEdicao(id);
} catch (e) {
// Perfis/departamentos não vieram: fica na lista em vez de abrir um
// formulário pela metade.
await pidAlert(`${e.message} Tente novamente; se persistir, contate a Inovação.`, {
titulo: "Não foi possível abrir o usuário",
});
}
}
async function preencherEdicao(id) {
formError.textContent = ""; formError.textContent = "";
departamentoError.textContent = ""; departamentoError.textContent = "";
departamentoNewInput.value = ""; departamentoNewInput.value = "";
@ -282,6 +344,7 @@ document.addEventListener("DOMContentLoaded", async () => {
fillLideradosChecklist(account.liderados.map((u) => u.id), account.id); fillLideradosChecklist(account.liderados.map((u) => u.id), account.id);
} }
retratoEdicao = retratoAtual();
listView.hidden = true; listView.hidden = true;
editView.hidden = false; editView.hidden = false;
} }
@ -289,9 +352,14 @@ document.addEventListener("DOMContentLoaded", async () => {
function closeEdit() { function closeEdit() {
editView.hidden = true; editView.hidden = true;
listView.hidden = false; listView.hidden = false;
retratoEdicao = null;
renderList(); renderList();
} }
async function tentarFecharEdicao() {
if (await podeSairDaEdicao()) closeEdit();
}
searchInput.addEventListener("input", renderList); searchInput.addEventListener("input", renderList);
sortHeaders.forEach((th) => { sortHeaders.forEach((th) => {
th.addEventListener("click", () => { th.addEventListener("click", () => {
@ -313,16 +381,21 @@ document.addEventListener("DOMContentLoaded", async () => {
renderList(); renderList();
}); });
newBtn.addEventListener("click", () => openEdit(null)); newBtn.addEventListener("click", () => openEdit(null));
backBtn.addEventListener("click", closeEdit); backBtn.addEventListener("click", tentarFecharEdicao);
cancelBtn.addEventListener("click", closeEdit); cancelBtn.addEventListener("click", tentarFecharEdicao);
departamentoAddBtn.addEventListener("click", async () => { departamentoAddBtn.addEventListener("click", async () => {
const nome = departamentoNewInput.value.trim(); const nome = departamentoNewInput.value.trim();
if (!nome) { if (!nome) {
departamentoError.textContent = "Informe o nome do departamento."; departamentoError.textContent = "Informe o nome do departamento.";
departamentoNewInput.focus();
return; return;
} }
departamentoError.textContent = ""; departamentoError.textContent = "";
// Trava contra clique duplo (criaria o departamento duas vezes).
const textoOriginal = departamentoAddBtn.textContent;
departamentoAddBtn.disabled = true;
departamentoAddBtn.textContent = "Adicionando…";
try { try {
const novo = await pidCreateDepartamento({ nome }); const novo = await pidCreateDepartamento({ nome });
const selected = selectedDepartamentoIds(); const selected = selectedDepartamentoIds();
@ -331,6 +404,10 @@ document.addEventListener("DOMContentLoaded", async () => {
departamentoNewInput.value = ""; departamentoNewInput.value = "";
} catch (e) { } catch (e) {
departamentoError.textContent = e.message; departamentoError.textContent = e.message;
departamentoNewInput.focus();
} finally {
departamentoAddBtn.disabled = false;
departamentoAddBtn.textContent = textoOriginal;
} }
}); });
@ -360,12 +437,14 @@ document.addEventListener("DOMContentLoaded", async () => {
const dep = departamentosCache.find((d) => d.id === id); const dep = departamentosCache.find((d) => d.id === id);
if (!dep) return; if (!dep) return;
if (!(await pidConfirm(`Excluir o departamento "${dep.nome}"?`, { perigoso: true }))) return; if (!(await pidConfirm(`Excluir o departamento "${dep.nome}"?`, { perigoso: true }))) return;
deleteBtn.disabled = true;
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) {
await pidAlert(e.message); deleteBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível excluir o departamento" });
} }
} }
}); });
@ -387,9 +466,15 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
const account = users.find((u) => u.id === id); const account = users.find((u) => u.id === id);
if (account && (await pidConfirm(`Excluir o usuário "${account.nome}"?`, { perigoso: true }))) { if (account && (await pidConfirm(`Excluir o usuário "${account.nome}"?`, { perigoso: true }))) {
deleteTarget.disabled = true;
try {
await pidDeleteUsuario(id); await pidDeleteUsuario(id);
users = await pidFetchUsuarios(); users = await pidFetchUsuarios();
renderList(); renderList();
} catch (e) {
deleteTarget.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível excluir o usuário" });
}
} }
return; return;
} }
@ -403,62 +488,56 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
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"; // Desativar limpa perfis e liderança no servidor (_revogar_acesso_se_inativo):
if (await pidConfirm(`${acao} o usuário "${account.nome}"?`)) { // quem confirma precisa saber que reativar não devolve isso.
const confirmado = account.is_active
? await pidConfirm(
`Desativar o usuário "${account.nome}"? Isso remove todos os perfis de acesso dele e o tira da liderança de quem o lidera. Ao reativar, será preciso configurar tudo de novo.`,
{ titulo: "Desativar usuário", textoConfirmar: "Desativar", perigoso: true }
)
: await pidConfirm(`Ativar o usuário "${account.nome}"?`, { titulo: "Ativar usuário", textoConfirmar: "Ativar" });
if (confirmado) {
toggleTarget.disabled = true;
try {
await pidUpdateUsuario(id, { is_active: !account.is_active }); await pidUpdateUsuario(id, { is_active: !account.is_active });
users = await pidFetchUsuarios(); users = await pidFetchUsuarios();
renderList(); renderList();
} catch (e) {
toggleTarget.disabled = false;
await pidAlert(e.message, {
titulo: account.is_active ? "Não foi possível desativar o usuário" : "Não foi possível ativar o usuário",
});
}
} }
} }
}); });
saveBtn.addEventListener("click", async () => { saveBtn.addEventListener("click", async () => {
const username = loginInput.value.trim(); const payload = coletarFormulario();
const nome = nomeInput.value.trim();
const senha = senhaInput.value;
const perfis = Array.from(profileChecklist.querySelectorAll('input[type="checkbox"]:checked')).map((cb) =>
Number(cb.value)
);
const departamentos = selectedDepartamentoIds();
const email = emailInput.value.trim();
const codigo_folha = codigoFolhaInput.value.trim();
const codigo_questor = codigoQuestorInput.value.trim();
const codigo_tareffa = codigoTareffaInput.value.trim();
const codigo_contabit = codigoContabitInput.value.trim();
const ramal = ramalInput.value.trim();
const data_aniversario = dataAniversarioInput.value || null;
const lideranca = liderancaInput.checked;
const liderados = selectedLideradosIds();
if (!username || !nome) { // A mensagem fica no fim do formulário; o cursor vai pro campo que falta.
formError.textContent = "Informe login e nome do usuário."; if (!payload.username) {
formError.textContent = "Informe o login do usuário.";
loginInput.focus();
return; return;
} }
if (editingId === null && !senha) { if (!payload.nome) {
formError.textContent = "Informe o nome do usuário.";
nomeInput.focus();
return;
}
if (editingId === null && !payload.senha) {
formError.textContent = "Informe uma senha para o novo usuário."; formError.textContent = "Informe uma senha para o novo usuário.";
senhaInput.focus();
return; return;
} }
formError.textContent = ""; formError.textContent = "";
const payload = { // Trava contra clique duplo (criaria a conta duas vezes).
username, const textoOriginal = saveBtn.textContent;
nome, saveBtn.disabled = true;
senha, saveBtn.textContent = "Salvando…";
perfis,
departamentos,
email,
codigo_folha,
codigo_questor,
codigo_tareffa,
codigo_contabit,
ramal,
data_aniversario,
lideranca,
liderados,
is_active: ativoInput.checked,
};
try { try {
if (editingId === null) { if (editingId === null) {
await pidCreateUsuario(payload); await pidCreateUsuario(payload);
@ -468,7 +547,12 @@ document.addEventListener("DOMContentLoaded", async () => {
users = await pidFetchUsuarios(); users = await pidFetchUsuarios();
closeEdit(); closeEdit();
} catch (e) { } catch (e) {
// Erro do servidor não diz o campo: ao menos rola até a mensagem.
formError.textContent = e.message; formError.textContent = e.message;
formError.scrollIntoView({ block: "center", behavior: "smooth" });
} finally {
saveBtn.disabled = false;
saveBtn.textContent = textoOriginal;
} }
}); });

View File

@ -23,6 +23,14 @@ const PID_WIDGET_TYPES = {
const PID_WIDGET_LINK_DEFAULT_ICON = const PID_WIDGET_LINK_DEFAULT_ICON =
'<svg class="widget-links-list__icon widget-links-list__icon--default" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'; '<svg class="widget-links-list__icon widget-links-list__icon--default" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>';
const PID_WIDGET_DATA_FORMATO = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit" });
const PID_WIDGET_DATA_HORA_FORMATO = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
const PID_WIDGET_DEFAULT_WIDTH = 320; const PID_WIDGET_DEFAULT_WIDTH = 320;
const PID_WIDGET_DEFAULT_HEIGHT = 240; const PID_WIDGET_DEFAULT_HEIGHT = 240;
@ -53,6 +61,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const addBtn = document.getElementById("widget-add-btn"); const addBtn = document.getElementById("widget-add-btn");
const pickerModal = document.getElementById("widget-picker-modal"); const pickerModal = document.getElementById("widget-picker-modal");
const pickerList = document.getElementById("widget-picker-list"); const pickerList = document.getElementById("widget-picker-list");
const textoWidgetsVazio = widgetsEmpty ? widgetsEmpty.textContent : "";
const PID_WIDGET_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
function todayIso() { function todayIso() {
const d = new Date(); const d = new Date();
@ -75,9 +85,18 @@ document.addEventListener("DOMContentLoaded", async () => {
return upcoming return upcoming
.map((ev) => { .map((ev) => {
const [, month, day] = ev.data.split("-"); // ev.data é data pura ("AAAA-MM-DD"): monta pelas partes no fuso local
// (new Date("AAAA-MM-DD") seria UTC e cairia no dia anterior).
const [ano, mes, dia] = ev.data.split("-").map(Number);
const horario = ev.horario ? ev.horario.slice(0, 5) : ""; const horario = ev.horario ? ev.horario.slice(0, 5) : "";
const dateLabel = horario ? `${day}/${month} ${horario}` : `${day}/${month}`; let dateLabel;
if (horario) {
const [hora, minuto] = horario.split(":").map(Number);
// pt-BR devolve "05/10, 09:03": tira a vírgula pra manter o "05/10 09:03" de antes.
dateLabel = PID_WIDGET_DATA_HORA_FORMATO.format(new Date(ano, mes - 1, dia, hora, minuto)).replace(",", "");
} else {
dateLabel = PID_WIDGET_DATA_FORMATO.format(new Date(ano, mes - 1, dia));
}
return ` return `
<div class="widget-agenda-row"> <div class="widget-agenda-row">
<span class="widget-agenda-row__date">${pidEscapeHtml(dateLabel)}</span> <span class="widget-agenda-row__date">${pidEscapeHtml(dateLabel)}</span>
@ -101,7 +120,7 @@ document.addEventListener("DOMContentLoaded", async () => {
${favoritos ${favoritos
.map((link) => { .map((link) => {
const iconHtml = link.icone const iconHtml = link.icone
? `<img class="widget-links-list__icon" src="${pidEscapeHtml(link.icone)}" alt="" />` ? `<img class="widget-links-list__icon" src="${pidEscapeHtml(link.icone)}" alt="" width="20" height="20" />`
: PID_WIDGET_LINK_DEFAULT_ICON; : PID_WIDGET_LINK_DEFAULT_ICON;
return ` return `
<a class="widget-links-list__row" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer"> <a class="widget-links-list__row" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">
@ -161,7 +180,11 @@ document.addEventListener("DOMContentLoaded", async () => {
resizeSaveTimers.set( resizeSaveTimers.set(
tipo, tipo,
setTimeout(() => { setTimeout(() => {
pidUpdateWidget(tipo, { largura: width, altura: height }).catch(() => {}); pidUpdateWidget(tipo, { largura: width, altura: height }).catch((e) => {
pidAlert(`O novo tamanho do widget não foi salvo e voltará ao anterior ao recarregar a página. ${e.message}`, {
titulo: "Não foi possível salvar o tamanho",
});
});
}, 500) }, 500)
); );
} }
@ -189,15 +212,35 @@ document.addEventListener("DOMContentLoaded", async () => {
return type && (!type.visibleIf || type.visibleIf(me)); return type && (!type.visibleIf || type.visibleIf(me));
}); });
const bodies = await Promise.all(widgets.map((w) => widgetBodyFor(w.tipo))); // Falha no conteúdo de um widget fica só nele (e não vira "nenhum compromisso").
const bodies = await Promise.all(
widgets.map((w) =>
widgetBodyFor(w.tipo).catch(() => `<p class="widget-card__empty">${PID_WIDGET_ERRO_CARGA}</p>`)
)
);
widgetBodyCache = {}; widgetBodyCache = {};
widgets.forEach((w, idx) => { widgets.forEach((w, idx) => {
widgetBodyCache[w.tipo] = bodies[idx]; widgetBodyCache[w.tipo] = bodies[idx];
}); });
if (widgetsEmpty) widgetsEmpty.textContent = textoWidgetsVazio;
renderWidgetsDom(); renderWidgetsDom();
} }
/** renderWidgets com tratamento de erro: falha ao buscar a lista de widgets
* mostra o aviso no lugar do "Nenhum widget adicionado". */
async function carregarWidgets() {
try {
await renderWidgets();
} catch (e) {
widgetsGrid.querySelectorAll(".widget-card").forEach((el) => el.remove());
if (widgetsEmpty) {
widgetsEmpty.textContent = PID_WIDGET_ERRO_CARGA;
widgetsEmpty.hidden = false;
}
}
}
async function renderPicker() { async function renderPicker() {
const added = widgets.map((w) => w.tipo); const added = widgets.map((w) => w.tipo);
const available = Object.keys(PID_WIDGET_TYPES).filter((key) => { const available = Object.keys(PID_WIDGET_TYPES).filter((key) => {
@ -224,24 +267,64 @@ document.addEventListener("DOMContentLoaded", async () => {
pickerModal.hidden = false; pickerModal.hidden = false;
}); });
const pickerCloseBtn = document.getElementById("widget-picker-close-btn");
if (pickerCloseBtn) {
pickerCloseBtn.addEventListener("click", () => {
pickerModal.hidden = true;
});
}
// Enquanto um widget está sendo adicionado, todas as opções ficam travadas:
// clique duplo mandaria dois POST.
let adicionandoWidget = false;
pickerModal.addEventListener("click", async (event) => { pickerModal.addEventListener("click", async (event) => {
if (event.target === pickerModal) { if (event.target === pickerModal) {
pickerModal.hidden = true; pickerModal.hidden = true;
return; return;
} }
const choice = event.target.closest("[data-widget-choice]"); const choice = event.target.closest("[data-widget-choice]");
if (!choice) return; if (!choice || adicionandoWidget) return;
const key = choice.getAttribute("data-widget-choice"); const key = choice.getAttribute("data-widget-choice");
const opcoes = pickerList.querySelectorAll("[data-widget-choice]");
adicionandoWidget = true;
opcoes.forEach((btn) => {
btn.disabled = true;
});
try {
await pidAddWidget(key); await pidAddWidget(key);
pickerModal.hidden = true; pickerModal.hidden = true;
await renderWidgets(); } catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível adicionar o widget" });
return;
} finally {
adicionandoWidget = false;
opcoes.forEach((btn) => {
btn.disabled = false;
});
}
await carregarWidgets();
}); });
widgetsGrid.addEventListener("click", async (event) => { widgetsGrid.addEventListener("click", async (event) => {
const removeBtn = event.target.closest("[data-widget-remove]"); const removeBtn = event.target.closest("[data-widget-remove]");
if (!removeBtn) return; if (!removeBtn || removeBtn.disabled) return;
await pidRemoveWidget(removeBtn.getAttribute("data-widget-remove")); const tipo = removeBtn.getAttribute("data-widget-remove");
await renderWidgets(); const type = PID_WIDGET_TYPES[tipo];
const confirmado = await pidConfirm(`Remover o widget "${type ? type.label : tipo}" da tela inicial?`, {
titulo: "Remover widget",
textoConfirmar: "Remover",
});
if (!confirmado) return;
removeBtn.disabled = true;
try {
await pidRemoveWidget(tipo);
} catch (e) {
removeBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível remover o widget" });
return;
}
await carregarWidgets();
}); });
let draggedTipo = null; let draggedTipo = null;
@ -302,8 +385,13 @@ document.addEventListener("DOMContentLoaded", async () => {
widgets = reordered; widgets = reordered;
renderWidgetsDom(); renderWidgetsDom();
try {
await Promise.all(updates.map(({ widget, index }) => pidUpdateWidget(widget.tipo, { ordem: index }))); await Promise.all(updates.map(({ widget, index }) => pidUpdateWidget(widget.tipo, { ordem: index })));
await renderWidgets(); } catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível salvar a nova ordem" });
}
// Recarrega sempre do servidor: se algum PATCH falhou, volta à ordem gravada.
await carregarWidgets();
}); });
widgetsGrid.addEventListener("dragend", () => { widgetsGrid.addEventListener("dragend", () => {
@ -315,5 +403,5 @@ document.addEventListener("DOMContentLoaded", async () => {
.forEach((el) => el.classList.remove("widget-card--dragging")); .forEach((el) => el.classList.remove("widget-card--dragging"));
}); });
await renderWidgets(); await carregarWidgets();
}); });

View File

@ -377,12 +377,12 @@
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ag-form-url">URL</label> <label for="ag-form-url">URL</label>
<input type="url" id="ag-form-url" placeholder="https://" autocomplete="off" /> <input type="url" id="ag-form-url" placeholder="https://" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="ag-form-usuario">Usuário</label> <label for="ag-form-usuario">Usuário</label>
<input type="text" id="ag-form-usuario" autocomplete="off" /> <input type="text" id="ag-form-usuario" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ag-form-senha">Senha</label> <label for="ag-form-senha">Senha</label>
@ -397,7 +397,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ag-form-observacoes">Observações</label> <label for="ag-form-observacoes">Observações</label>
<div id="ag-form-observacoes" class="ag-richtext" contenteditable="true" data-placeholder="Escreva aqui — cole (Ctrl+V) ou arraste uma imagem para incluí-la no texto"></div> <div id="ag-form-observacoes" class="ag-richtext" contenteditable="true" data-placeholder="Escreva aqui — cole (Ctrl+V) ou arraste uma imagem para incluí-la no texto"></div>
<p class="ag-richtext-hint">Imagens até 2MB cada.</p> <p class="ag-richtext-hint">Imagens até 2&nbsp;MB cada.</p>
</div> </div>
<p class="modal-error" id="ag-form-error"></p> <p class="modal-error" id="ag-form-error"></p>
<div class="modal-actions"> <div class="modal-actions">

View File

@ -363,7 +363,7 @@
<h2 class="modal-card__title" id="ic-modal-title">Novo Compromisso</h2> <h2 class="modal-card__title" id="ic-modal-title">Novo Compromisso</h2>
<div class="modal-field"> <div class="modal-field">
<label for="ic-event-title">Título</label> <label for="ic-event-title">Título</label>
<input type="text" id="ic-event-title" placeholder="Ex.: Reunião com cliente" /> <input type="text" id="ic-event-title" placeholder="Ex.: Reunião com cliente" autocomplete="off" />
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
@ -403,7 +403,7 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="ic-event-local">Local</label> <label for="ic-event-local">Local</label>
<input type="text" id="ic-event-local" placeholder="Ex.: Auditório" /> <input type="text" id="ic-event-local" placeholder="Ex.: Auditório" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ic-event-modalidade">Modalidade</label> <label for="ic-event-modalidade">Modalidade</label>
@ -471,7 +471,7 @@
</div> </div>
<div class="modal-field" id="ic-view-descricao-field" hidden> <div class="modal-field" id="ic-view-descricao-field" hidden>
<label>Descrição</label> <label>Descrição</label>
<p id="ic-view-descricao"></p> <p class="ic-view-descricao" id="ic-view-descricao"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="ic-view-close-btn">Fechar</button> <button type="button" class="btn-outline" id="ic-view-close-btn">Fechar</button>
@ -484,7 +484,7 @@
<h2 class="modal-card__title">Nova Categoria de Evento</h2> <h2 class="modal-card__title">Nova Categoria de Evento</h2>
<div class="modal-field"> <div class="modal-field">
<label for="ic-categoria-nome">Nome</label> <label for="ic-categoria-nome">Nome</label>
<input type="text" id="ic-categoria-nome" placeholder="Ex.: Palestra" /> <input type="text" id="ic-categoria-nome" placeholder="Ex.: Palestra" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ic-categoria-cor">Cor</label> <label for="ic-categoria-cor">Cor</label>
@ -549,7 +549,7 @@
registros registros
</label> </label>
<div class="ram-lookup-search"> <div class="ram-lookup-search">
<input type="text" id="ramais-lookup-search" placeholder="Pesquisar por nome, departamento ou ramal..." autocomplete="off" /> <input type="text" id="ramais-lookup-search" placeholder="Pesquisar por nome, departamento ou ramal…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -575,7 +575,7 @@
</div> </div>
<div class="modal-actions modal-actions--spread"> <div class="modal-actions modal-actions--spread">
<button type="button" class="btn-outline" id="ramais-lookup-close-btn-2">Close</button> <button type="button" class="btn-outline" id="ramais-lookup-close-btn-2">Fechar</button>
<a href="ramais.html" class="btn-solid">Ir para Controle de Ramais</a> <a href="ramais.html" class="btn-solid">Ir para Controle de Ramais</a>
</div> </div>
</div> </div>

View File

@ -350,7 +350,7 @@
<header class="cfr-header"> <header class="cfr-header">
<div class="cfr-header__brand"> <div class="cfr-header__brand">
{# cópia reduzida de logo-branco.png (6250 px de largura pesava mais que o PDF inteiro) #} {# cópia reduzida de logo-branco.png (6250 px de largura pesava mais que o PDF inteiro) #}
<img src="{% static 'img/logo-branco-relatorio.png' %}" alt="De Paula Contadores"> <img src="{% if logo_data_uri %}{{ logo_data_uri }}{% else %}{% static 'img/logo-branco-relatorio.png' %}{% endif %}" alt="De Paula Contadores">
<div> <div>
<h1>Conciliação de Fornecedores</h1> <h1>Conciliação de Fornecedores</h1>
<p> <p>
@ -534,7 +534,7 @@
<td>{{ v.fornecedor }}</td> <td>{{ v.fornecedor }}</td>
<td><span class="cfr-pill cfr-pill--{{ v.status }}">{{ v.status_rotulo }}</span></td> <td><span class="cfr-pill cfr-pill--{{ v.status }}">{{ v.status_rotulo }}</span></td>
<td><ul class="cfr-lista-alertas">{% for a in v.alertas %}<li>{{ a }}</li>{% endfor %}</ul></td> <td><ul class="cfr-lista-alertas">{% for a in v.alertas %}<li>{{ a }}</li>{% endfor %}</ul></td>
<td>{{ v.validada_por }}<br><span class="cfr-muted cfr-nowrap">{{ v.validada_em|date:"d/m/y H:i" }}</span></td> <td>{{ v.validada_por }}<br><span class="cfr-muted cfr-nowrap">{{ v.validada_em|date:"d/m/Y H:i" }}</span></td>
<td class="cfr-justificativa">{% if v.observacao %}{{ v.observacao|linebreaksbr }}{% else %}<span class="cfr-sem-just">Sem justificativa</span>{% endif %}</td> <td class="cfr-justificativa">{% if v.observacao %}{{ v.observacao|linebreaksbr }}{% else %}<span class="cfr-sem-just">Sem justificativa</span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -560,7 +560,7 @@
<td class="cfr-centro">{% if a.desfez_automatico %}<strong>{{ a.desfez_automatico }}</strong>{% else %}0{% endif %}</td> <td class="cfr-centro">{% if a.desfez_automatico %}<strong>{{ a.desfez_automatico }}</strong>{% else %}0{% endif %}</td>
<td class="cfr-centro">{{ a.desfez_manual }}</td> <td class="cfr-centro">{{ a.desfez_manual }}</td>
<td>{{ a.usuarios|join:", "|default:"—" }}</td> <td>{{ a.usuarios|join:", "|default:"—" }}</td>
<td class="cfr-muted cfr-nowrap">{{ a.ultima_acao|date:"d/m/y H:i" }}</td> <td class="cfr-muted cfr-nowrap">{{ a.ultima_acao|date:"d/m/Y H:i" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -603,7 +603,7 @@
<td class="cfr-centro">{{ v.qtd_titulos }}</td> <td class="cfr-centro">{{ v.qtd_titulos }}</td>
<td>{{ v.mais_antigo|date:"d/m/Y" }}</td> <td>{{ v.mais_antigo|date:"d/m/Y" }}</td>
<td class="cfr-valor">{{ v.valor|moeda }}</td> <td class="cfr-valor">{{ v.valor|moeda }}</td>
<td>{{ v.revisado_por|default:"—" }}<br><span class="cfr-muted cfr-nowrap">{{ v.revisado_em|date:"d/m/y H:i" }}</span></td> <td>{{ v.revisado_por|default:"—" }}<br><span class="cfr-muted cfr-nowrap">{{ v.revisado_em|date:"d/m/Y H:i" }}</span></td>
<td class="cfr-justificativa">{% if v.observacao %}{{ v.observacao|linebreaksbr }}{% else %}<em>Sem observação</em>{% endif %}</td> <td class="cfr-justificativa">{% if v.observacao %}{{ v.observacao|linebreaksbr }}{% else %}<em>Sem observação</em>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -345,12 +345,19 @@
<div class="conc-form__grid"> <div class="conc-form__grid">
<div class="modal-field"> <div class="modal-field">
<label for="conc-form-codigo">Código da empresa</label> <label for="conc-form-codigo">Código da empresa</label>
<input type="text" id="conc-form-codigo" inputmode="numeric" autocomplete="off" placeholder="Ex.: 2017" /> <input type="text" id="conc-form-codigo" name="codigo_empresa" inputmode="numeric" autocomplete="off" placeholder="Ex.: 2017…" />
<p class="conc-form__empresa" id="conc-form-empresa"></p> <p class="conc-form__empresa" id="conc-form-empresa"></p>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="conc-form-periodo-fim">Razão até</label> <label for="conc-form-periodo-fim-texto">Razão até</label>
<input type="date" id="conc-form-periodo-fim" /> <div class="conc-data-field">
<input type="text" id="conc-form-periodo-fim-texto" name="periodo_fim" inputmode="numeric" maxlength="10" placeholder="dd/mm/aaaa" autocomplete="off" />
<button type="button" class="conc-data-field__btn" aria-label="Abrir calendário">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4" stroke-linecap="round"/></svg>
</button>
<input type="date" id="conc-form-periodo-fim" class="conc-data-field__picker" tabindex="-1" aria-hidden="true" />
</div>
<p class="conc-form__dica conc-data-field__aviso" hidden></p>
<p class="conc-form__dica">Data final da exportação do Questor. O início vem do arquivo.</p> <p class="conc-form__dica">Data final da exportação do Questor. O início vem do arquivo.</p>
</div> </div>
</div> </div>
@ -368,7 +375,7 @@
<p class="modal-error" id="conc-form-erro"></p> <p class="modal-error" id="conc-form-erro"></p>
<div class="conc-progress" id="conc-form-progress" hidden> <div class="conc-progress" id="conc-form-progress" hidden>
<div class="conc-progress__bar"><div class="conc-progress__fill"></div></div> <div class="conc-progress__bar"><div class="conc-progress__fill"></div></div>
<p class="conc-progress__label">Lendo o razão e conciliando os fornecedores, aguarde...</p> <p class="conc-progress__label">Lendo o razão e conciliando os fornecedores, aguarde…</p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="conc-form-cancelar-btn">Cancelar</button> <button type="button" class="btn-outline" id="conc-form-cancelar-btn">Cancelar</button>
@ -378,7 +385,7 @@
<div class="conc-toolbar"> <div class="conc-toolbar">
<div class="pa-search"> <div class="pa-search">
<input type="search" id="conc-lista-busca" placeholder="Buscar por código ou empresa..." autocomplete="off" /> <input type="search" id="conc-lista-busca" name="busca_conciliacao" placeholder="Buscar por código ou empresa…" autocomplete="off" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -453,7 +460,7 @@
</div> </div>
<label class="conc-toggle"><input type="checkbox" id="conc-mostrar-validadas" /> Mostrar validadas <span class="conc-chip__count" id="conc-mostrar-validadas-qtd">0</span></label> <label class="conc-toggle"><input type="checkbox" id="conc-mostrar-validadas" /> Mostrar validadas <span class="conc-chip__count" id="conc-mostrar-validadas-qtd">0</span></label>
<div class="pa-search"> <div class="pa-search">
<input type="search" id="conc-contas-busca" placeholder="Buscar fornecedor ou conta..." autocomplete="off" /> <input type="search" id="conc-contas-busca" name="busca_fornecedor" placeholder="Buscar fornecedor ou conta…" autocomplete="off" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -487,7 +494,7 @@
<button type="button" class="conc-chip" data-conc-venc="">Todos</button> <button type="button" class="conc-chip" data-conc-venc="">Todos</button>
</div> </div>
<div class="pa-search"> <div class="pa-search">
<input type="search" id="conc-venc-busca" placeholder="Buscar fornecedor ou conta..." autocomplete="off" /> <input type="search" id="conc-venc-busca" name="busca_fornecedor_vencidos" placeholder="Buscar fornecedor ou conta…" autocomplete="off" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -609,14 +616,21 @@
</div> </div>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="conc-atualizar-periodo-fim">Razão até</label> <label for="conc-atualizar-periodo-fim-texto">Razão até</label>
<input type="date" id="conc-atualizar-periodo-fim" /> <div class="conc-data-field">
<input type="text" id="conc-atualizar-periodo-fim-texto" name="periodo_fim" inputmode="numeric" maxlength="10" placeholder="dd/mm/aaaa" autocomplete="off" />
<button type="button" class="conc-data-field__btn" aria-label="Abrir calendário">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4" stroke-linecap="round"/></svg>
</button>
<input type="date" id="conc-atualizar-periodo-fim" class="conc-data-field__picker" tabindex="-1" aria-hidden="true" />
</div>
<p class="conc-form__dica conc-data-field__aviso" hidden></p>
<p class="conc-form__dica">Data final da exportação do Questor, mesmo sem lançamento nos últimos dias. O início vem do arquivo.</p> <p class="conc-form__dica">Data final da exportação do Questor, mesmo sem lançamento nos últimos dias. O início vem do arquivo.</p>
</div> </div>
<p class="modal-error" id="conc-atualizar-erro"></p> <p class="modal-error" id="conc-atualizar-erro"></p>
<div class="conc-progress" id="conc-atualizar-progress" hidden> <div class="conc-progress" id="conc-atualizar-progress" hidden>
<div class="conc-progress__bar"><div class="conc-progress__fill"></div></div> <div class="conc-progress__bar"><div class="conc-progress__fill"></div></div>
<p class="conc-progress__label">Lendo o razão novo e refazendo a análise, aguarde...</p> <p class="conc-progress__label">Lendo o razão novo e refazendo a análise, aguarde…</p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="conc-atualizar-cancelar-btn">Cancelar</button> <button type="button" class="btn-outline" id="conc-atualizar-cancelar-btn">Cancelar</button>

View File

@ -357,7 +357,7 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="cc-inss-teto">Teto do desconto de INSS (valor fixo acima da última faixa)</label> <label for="cc-inss-teto">Teto do desconto de INSS (valor fixo acima da última faixa)</label>
<input type="text" id="cc-inss-teto" autocomplete="off" /> <input type="text" id="cc-inss-teto" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>
</div> </div>
@ -374,11 +374,11 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="cc-irrf-topo-aliquota">Alíquota acima da última faixa (%)</label> <label for="cc-irrf-topo-aliquota">Alíquota acima da última faixa (%)</label>
<input type="text" id="cc-irrf-topo-aliquota" autocomplete="off" /> <input type="text" id="cc-irrf-topo-aliquota" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-irrf-topo-deduzir">Deduzir acima da última faixa (R$)</label> <label for="cc-irrf-topo-deduzir">Deduzir acima da última faixa (R$)</label>
<input type="text" id="cc-irrf-topo-deduzir" autocomplete="off" /> <input type="text" id="cc-irrf-topo-deduzir" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>
</div> </div>
@ -388,11 +388,11 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="cc-desconto-simplificado">Desconto simplificado do IRRF (R$)</label> <label for="cc-desconto-simplificado">Desconto simplificado do IRRF (R$)</label>
<input type="text" id="cc-desconto-simplificado" autocomplete="off" /> <input type="text" id="cc-desconto-simplificado" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-deducao-dependente">Dedução por dependente (R$)</label> <label for="cc-deducao-dependente">Dedução por dependente (R$)</label>
<input type="text" id="cc-deducao-dependente" autocomplete="off" /> <input type="text" id="cc-deducao-dependente" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>
</div> </div>
@ -403,15 +403,15 @@
<div class="modal-field-row cc-field-row--3"> <div class="modal-field-row cc-field-row--3">
<div class="modal-field"> <div class="modal-field">
<label for="cc-lei15270-a">Coeficiente A (R$)</label> <label for="cc-lei15270-a">Coeficiente A (R$)</label>
<input type="text" id="cc-lei15270-a" autocomplete="off" /> <input type="text" id="cc-lei15270-a" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-lei15270-b">Coeficiente B</label> <label for="cc-lei15270-b">Coeficiente B</label>
<input type="text" id="cc-lei15270-b" placeholder="Ex.: 0.133145" autocomplete="off" /> <input type="text" id="cc-lei15270-b" placeholder="Ex.: 0,133145…" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-lei15270-limite">Limite (R$)</label> <label for="cc-lei15270-limite">Limite (R$)</label>
<input type="text" id="cc-lei15270-limite" autocomplete="off" /> <input type="text" id="cc-lei15270-limite" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>
</div> </div>
@ -446,7 +446,7 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="cc-salario">Salário contratual</label> <label for="cc-salario">Salário contratual</label>
<input type="text" id="cc-salario" placeholder="Ex.: 8.500,00" autocomplete="off" /> <input type="text" id="cc-salario" placeholder="Ex.: 8.500,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-adicional">Adicional Art. 62.</label> <label for="cc-adicional">Adicional Art. 62.</label>
@ -456,11 +456,11 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="cc-comissao">Comissão</label> <label for="cc-comissao">Comissão</label>
<input type="text" id="cc-comissao" placeholder="0,00" autocomplete="off" /> <input type="text" id="cc-comissao" placeholder="0,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-dsr">DSR</label> <label for="cc-dsr">DSR</label>
<input type="text" id="cc-dsr" placeholder="0,00" autocomplete="off" /> <input type="text" id="cc-dsr" placeholder="0,00" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>
@ -479,11 +479,11 @@
<div class="modal-field-row cc-field-row--3"> <div class="modal-field-row cc-field-row--3">
<div class="modal-field"> <div class="modal-field">
<label for="cc-perc-inss">% de INSS Empregador</label> <label for="cc-perc-inss">% de INSS Empregador</label>
<input type="text" id="cc-perc-inss" placeholder="Ex.: 26" autocomplete="off" /> <input type="text" id="cc-perc-inss" placeholder="Ex.: 26" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-perc-fgts">% de FGTS</label> <label for="cc-perc-fgts">% de FGTS</label>
<input type="text" id="cc-perc-fgts" placeholder="Ex.: 8" autocomplete="off" /> <input type="text" id="cc-perc-fgts" placeholder="Ex.: 8" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-dependentes">Dependentes menores de 21 anos</label> <label for="cc-dependentes">Dependentes menores de 21 anos</label>
@ -498,25 +498,25 @@
<div class="modal-field-row cc-field-row--3"> <div class="modal-field-row cc-field-row--3">
<div class="modal-field"> <div class="modal-field">
<label for="cc-vale-transporte">Vale Transporte</label> <label for="cc-vale-transporte">Vale Transporte</label>
<input type="text" id="cc-vale-transporte" placeholder="Ex.: 220,00" autocomplete="off" /> <input type="text" id="cc-vale-transporte" placeholder="Ex.: 220,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-seguro-vida">Seguro de vida</label> <label for="cc-seguro-vida">Seguro de vida</label>
<input type="text" id="cc-seguro-vida" placeholder="Ex.: 15,00" autocomplete="off" /> <input type="text" id="cc-seguro-vida" placeholder="Ex.: 15,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-vale-alimentacao">Vale Alimentação</label> <label for="cc-vale-alimentacao">Vale Alimentação</label>
<input type="text" id="cc-vale-alimentacao" placeholder="Ex.: 600,00" autocomplete="off" /> <input type="text" id="cc-vale-alimentacao" placeholder="Ex.: 600,00" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>
<div class="modal-field-row cc-field-row--3"> <div class="modal-field-row cc-field-row--3">
<div class="modal-field"> <div class="modal-field">
<label for="cc-assiduidade">Assiduidade</label> <label for="cc-assiduidade">Assiduidade</label>
<input type="text" id="cc-assiduidade" placeholder="Ex.: 100,00" autocomplete="off" /> <input type="text" id="cc-assiduidade" placeholder="Ex.: 100,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="cc-fundo-formacao">Fundo de Formação</label> <label for="cc-fundo-formacao">Fundo de Formação</label>
<input type="text" id="cc-fundo-formacao" placeholder="Ex.: 50,00" autocomplete="off" /> <input type="text" id="cc-fundo-formacao" placeholder="Ex.: 50,00" inputmode="decimal" autocomplete="off" />
</div> </div>
</div> </div>

View File

@ -229,14 +229,22 @@
} }
.dcr-tab:hover { color: var(--roxo-escuro); } .dcr-tab:hover { color: var(--roxo-escuro); }
.dcr-tab.is-active { color: var(--roxo-escuro); } .dcr-tab.is-active { color: var(--roxo-escuro); }
/* Largura base fixa de 100px: a largura da aba ativa vira um `scaleX()`
calculado em JS (posicionaIndicador), junto do `translateX()`. Anima só
`transform` (composição na GPU), nunca `width` (que refaz o layout a
cada quadro). `transform-origin: left` faz a escala crescer a partir da
borda esquerda, alinhada com o `translateX`. */
.dcr-tabs__indicator { .dcr-tabs__indicator {
position: absolute; position: absolute;
bottom: -2px; bottom: -2px;
left: 0; left: 0;
width: 100px;
height: 3px; height: 3px;
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
background: linear-gradient(90deg, var(--dourado), var(--dourado-vivo)); background: linear-gradient(90deg, var(--dourado), var(--dourado-vivo));
transition: transform 280ms cubic-bezier(0.65, 0, 0.35, 1), width 280ms cubic-bezier(0.65, 0, 0.35, 1); transform-origin: left;
transform: scaleX(0);
transition: transform 280ms cubic-bezier(0.65, 0, 0.35, 1);
} }
.dcr-tab-panel { animation: dcrFadeSlideUp 420ms ease 140ms both; } .dcr-tab-panel { animation: dcrFadeSlideUp 420ms ease 140ms both; }
@ -280,6 +288,15 @@
min-height: 172px; min-height: 172px;
animation: dcrFadeSlideUp 420ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: dcrFadeSlideUp 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 55ms + 60ms); animation-delay: calc(var(--i, 0) * 55ms + 60ms);
/* `backwards`, não `both`, só aqui: com `both` o quadro final da
animação (`transform: translateY(0)`) continuava aplicado depois do
fim e anulava o `transform` do hover logo abaixo, que nunca aparecia.
`backwards` segura o estado inicial durante o atraso (o card não
pisca visível antes de entrar) e, ao terminar, devolve o card ao
estilo normal, que é igual ao quadro final. A impressão não muda: o
`@media print` zera toda `animation` do mesmo jeito. */
animation-fill-mode: backwards;
transition: transform 200ms ease;
} }
.dcr-card:hover { transform: translateY(-3px); } .dcr-card:hover { transform: translateY(-3px); }
.dcr-card-inner { .dcr-card-inner {
@ -289,7 +306,15 @@
transition: transform 480ms cubic-bezier(0.4, 0.2, 0.2, 1); transition: transform 480ms cubic-bezier(0.4, 0.2, 0.2, 1);
transform-style: preserve-3d; transform-style: preserve-3d;
} }
.dcr-card:hover .dcr-card-inner { transform: rotateY(180deg); } .dcr-card:hover .dcr-card-inner,
.dcr-card.is-virado .dcr-card-inner { transform: rotateY(180deg); }
/* Em tela de toque não existe hover: o verso abre e fecha no toque
(`.is-virado`, ver o script no fim do documento). */
@media (hover: none) {
.dcr-card { cursor: pointer; }
.dcr-card:hover .dcr-card-inner { transform: none; }
.dcr-card.is-virado .dcr-card-inner { transform: rotateY(180deg); }
}
.dcr-card-face { .dcr-card-face {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -630,6 +655,11 @@
.dcr-tabs { overflow-x: auto; } .dcr-tabs { overflow-x: auto; }
} }
/* HTML salvo e aberto fora do Portal (enviado ao cliente): exportar
depende de login no Portal, então os botões somem (ver o script). */
.dcr-fora-do-portal .dcr-export-btn,
.dcr-fora-do-portal .dcr-resumo-toolbar { display: none; }
@media print { @media print {
* { animation: none !important; transition: none !important; } * { animation: none !important; transition: none !important; }
body { background: #fff; } body { background: #fff; }
@ -646,6 +676,8 @@
.dcr-toggle { display: none; } .dcr-toggle { display: none; }
.dcr-obs-btn { display: none; } .dcr-obs-btn { display: none; }
.dcr-card, .dcr-tabela-wrap, .dcr-obs-item, .dcr-resumo-fechamento { box-shadow: none !important; } .dcr-card, .dcr-tabela-wrap, .dcr-obs-item, .dcr-resumo-fechamento { box-shadow: none !important; }
/* Card virado no toque imprime a frente, como os demais. */
.dcr-card .dcr-card-inner { transform: none !important; }
} }
</style> </style>
</head> </head>
@ -653,7 +685,7 @@
<div class="dcr-page"> <div class="dcr-page">
<header class="dcr-header"> <header class="dcr-header">
<div class="dcr-header__brand"> <div class="dcr-header__brand">
<img src="{% static 'img/logo-branco.png' %}" alt="De Paula Contadores"> <img src="{% if logo_data_uri %}{{ logo_data_uri }}{% else %}{% static 'img/logo-branco-relatorio.png' %}{% endif %}" alt="De Paula Contadores">
<div> <div>
<h1>Relatório Contábil — {{ apuracao.nome_empresa }}</h1> <h1>Relatório Contábil — {{ apuracao.nome_empresa }}</h1>
<p> <p>
@ -690,7 +722,7 @@
<li class="dcr-obs-item{% if obs.ancora %} dcr-obs-item--clicavel{% endif %}"{% if obs.ancora %} data-dcr-obs-ir="{{ obs.ancora }}" role="button" tabindex="0"{% endif %} style="--i:{{ forloop.counter0 }}"> <li class="dcr-obs-item{% if obs.ancora %} dcr-obs-item--clicavel{% endif %}"{% if obs.ancora %} data-dcr-obs-ir="{{ obs.ancora }}" role="button" tabindex="0"{% endif %} style="--i:{{ forloop.counter0 }}">
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div> <div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div>
<div> <div>
<div class="dcr-obs-item__titulo">{{ obs.alvo_chave }} — {{ obs.alvo_rotulo }}</div> <div class="dcr-obs-item__titulo">{{ obs.alvo_chave|codigo_conta }} — {{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__texto">{{ obs.texto }}</div> <div class="dcr-obs-item__texto">{{ obs.texto }}</div>
<div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</div> <div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</div>
</div> </div>
@ -939,7 +971,7 @@
</tr> </tr>
{% if item.observacoes %} {% if item.observacoes %}
<tr class="dcr-obs-inline-row" data-dcr-obs-row="av-{{ item.linha.id }}" hidden> <tr class="dcr-obs-inline-row" data-dcr-obs-row="av-{{ item.linha.id }}" hidden>
<td colspan="{{ analise_vertical_meses|length|add:2 }}"> <td colspan="{{ analise_vertical_colunas }}">
{% for obs in item.observacoes %} {% for obs in item.observacoes %}
<div class="dcr-obs-inline"> <div class="dcr-obs-inline">
<span class="dcr-obs-inline__rotulo">Observação do contador · {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · registrada na competência {{ obs.competencia_origem|competencia }}{% endif %}</span> <span class="dcr-obs-inline__rotulo">Observação do contador · {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · registrada na competência {{ obs.competencia_origem|competencia }}{% endif %}</span>
@ -1021,7 +1053,7 @@
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}"> <li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div> <div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div>
<div> <div>
<div class="dcr-obs-item__titulo">Balancete — {{ obs.alvo_chave }} {{ obs.alvo_rotulo }}</div> <div class="dcr-obs-item__titulo">Balancete — {{ obs.alvo_chave|codigo_conta }} {{ obs.alvo_rotulo }}</div>
<div class="dcr-obs-item__texto">{{ obs.texto }}</div> <div class="dcr-obs-item__texto">{{ obs.texto }}</div>
<div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</div> <div class="dcr-obs-item__assinatura">{{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}</div>
</div> </div>
@ -1071,6 +1103,7 @@
</button> </button>
<script> <script>
if (window.location.protocol === "file:") document.documentElement.classList.add("dcr-fora-do-portal");
(function () { (function () {
// ---- Abas (Balancete / D.R.E. / Indicadores), com indicador deslizante ---- // ---- Abas (Balancete / D.R.E. / Indicadores), com indicador deslizante ----
var tabs = Array.prototype.slice.call(document.querySelectorAll("[data-dcr-tab]")); var tabs = Array.prototype.slice.call(document.querySelectorAll("[data-dcr-tab]"));
@ -1080,8 +1113,9 @@
function posicionaIndicador(tab) { function posicionaIndicador(tab) {
if (!indicador || !tab) return; if (!indicador || !tab) return;
indicador.style.width = tab.offsetWidth + "px"; // Largura base do indicador é 100px no CSS: a largura da aba vira
indicador.style.transform = "translateX(" + tab.offsetLeft + "px)"; // escala horizontal (só `transform` anima, nunca `width`).
indicador.style.transform = "translateX(" + tab.offsetLeft + "px) scaleX(" + tab.offsetWidth / 100 + ")";
} }
function ativaTab(tab) { function ativaTab(tab) {
@ -1562,6 +1596,18 @@
// `pidDcrAnimaContadores(true)` acima); (2) já foi clicada e a // `pidDcrAnimaContadores(true)` acima); (2) já foi clicada e a
// contagem pode estar em andamento — só finaliza os valores já em // contagem pode estar em andamento — só finaliza os valores já em
// animação. Um único listener, pra nunca competir com o outro caso. // animação. Um único listener, pra nunca competir com o outro caso.
// ---- Cards: verso no toque ----
// Com mouse o verso abre no hover (CSS puro); em tela de toque não há
// hover, então o toque alterna `.is-virado`. Só nesse caso, pra um
// clique de mouse não deixar o card preso no verso depois que o
// ponteiro sai dele.
var semHover = window.matchMedia && window.matchMedia("(hover: none)");
document.addEventListener("click", function (event) {
if (!semHover || !semHover.matches) return;
var card = event.target.closest && event.target.closest(".dcr-card");
if (card) card.classList.toggle("is-virado");
});
window.addEventListener("beforeprint", function () { window.addEventListener("beforeprint", function () {
if (!cardsInicializados) { if (!cardsInicializados) {
cardsInicializados = true; cardsInicializados = true;

View File

@ -354,7 +354,7 @@
<th data-sort="criado_por">Criado por <span class="dc-sort-icon">↕</span></th> <th data-sort="criado_por">Criado por <span class="dc-sort-icon">↕</span></th>
<th data-sort="criado_em">Data <span class="dc-sort-icon">↕</span></th> <th data-sort="criado_em">Data <span class="dc-sort-icon">↕</span></th>
<th> <th>
<button type="button" class="dc-list-reset-btn" id="dc-list-reset-btn" title="Limpar filtros e ordenação" aria-label="Limpar filtros e ordenação"> <button type="button" class="dc-list-reset-btn" id="dc-list-reset-btn" data-dc-dica="Limpar filtros e ordenação" aria-label="Limpar filtros e ordenação">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H8l-5.5-5.5a2 2 0 0 1 0-2.8l9-9a2 2 0 0 1 2.8 0l6.2 6.2a2 2 0 0 1 0 2.8L13 19" stroke-linejoin="round"/><path d="M8.5 12.5l6 6" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H8l-5.5-5.5a2 2 0 0 1 0-2.8l9-9a2 2 0 0 1 2.8 0l6.2 6.2a2 2 0 0 1 0 2.8L13 19" stroke-linejoin="round"/><path d="M8.5 12.5l6 6" stroke-linecap="round"/></svg>
</button> </button>
</th> </th>
@ -387,7 +387,7 @@
</label> </label>
<input type="file" id="dc-form-arquivo" accept=".pdf" class="dc-file-field__input" /> <input type="file" id="dc-form-arquivo" accept=".pdf" class="dc-file-field__input" />
<span class="dc-file-field__name" id="dc-form-arquivo-name">Nenhum arquivo selecionado</span> <span class="dc-file-field__name" id="dc-form-arquivo-name">Nenhum arquivo selecionado</span>
<button type="button" class="icon-btn dc-file-field__remove" id="dc-form-arquivo-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden> <button type="button" class="icon-btn dc-file-field__remove" id="dc-form-arquivo-remove" aria-label="Remover arquivo" data-dc-dica="Remover arquivo" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button> </button>
</div> </div>
@ -395,7 +395,7 @@
<p class="modal-error" id="dc-form-error"></p> <p class="modal-error" id="dc-form-error"></p>
<div class="dc-progress" id="dc-form-progress" hidden> <div class="dc-progress" id="dc-form-progress" hidden>
<div class="dc-progress__bar"><div class="dc-progress__fill"></div></div> <div class="dc-progress__bar"><div class="dc-progress__fill"></div></div>
<p class="dc-progress__label">Processando o arquivo, aguarde...</p> <p class="dc-progress__label">Processando o arquivo, aguarde…</p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="dc-form-cancel-btn">Cancelar</button> <button type="button" class="btn-outline" id="dc-form-cancel-btn">Cancelar</button>
@ -473,7 +473,7 @@
<tr> <tr>
<th> <th>
<span class="dc-th-linha dc-th-linha--inicio"> <span class="dc-th-linha dc-th-linha--inicio">
<button type="button" class="icon-btn dc-recolher-grupos-btn" data-dc-recolher-grupos="balancete" aria-label="Recolher os grupos do Balancete" title="Recolher os grupos (mostrar só os totais de cada nível)"> <button type="button" class="icon-btn dc-recolher-grupos-btn" data-dc-recolher-grupos="balancete" aria-label="Recolher os grupos do Balancete" data-dc-dica="Recolher os grupos (mostrar só os totais de cada nível)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/></svg>
</button> </button>
Conta Conta
@ -487,7 +487,7 @@
<th>Saldo Atual</th> <th>Saldo Atual</th>
<th> <th>
<span class="dc-th-linha">Observação <span class="dc-th-linha">Observação
<button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="balancete" aria-label="Restaurar formatação padrão do Balancete" title="Restaurar formatação padrão (tudo expandido, como ao abrir)"> <button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="balancete" aria-label="Restaurar formatação padrão do Balancete" data-dc-dica="Restaurar formatação padrão (tudo expandido, como ao abrir)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
</button> </button>
</span> </span>
@ -516,7 +516,7 @@
<tr> <tr>
<th> <th>
<span class="dc-th-linha dc-th-linha--inicio"> <span class="dc-th-linha dc-th-linha--inicio">
<button type="button" class="icon-btn dc-recolher-grupos-btn" data-dc-recolher-grupos="dre" aria-label="Recolher os grupos da DRE" title="Recolher os grupos (mostrar só os totais de cada nível)"> <button type="button" class="icon-btn dc-recolher-grupos-btn" data-dc-recolher-grupos="dre" aria-label="Recolher os grupos da DRE" data-dc-dica="Recolher os grupos (mostrar só os totais de cada nível)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/></svg>
</button> </button>
Descrição Descrição
@ -525,7 +525,7 @@
<th>Valor</th> <th>Valor</th>
<th> <th>
<span class="dc-th-linha">Observação <span class="dc-th-linha">Observação
<button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="dre" aria-label="Restaurar formatação padrão da DRE" title="Restaurar formatação padrão (tudo expandido, como ao abrir)"> <button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="dre" aria-label="Restaurar formatação padrão da DRE" data-dc-dica="Restaurar formatação padrão (tudo expandido, como ao abrir)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
</button> </button>
</span> </span>
@ -578,7 +578,7 @@
<div class="pa-tab-panel" data-dc-panel="dashboard" hidden> <div class="pa-tab-panel" data-dc-panel="dashboard" hidden>
<p class="dc-dash-intro"> <p class="dc-dash-intro">
Pré-visualização do que vai para o relatório gerado pelo botão "Gerar Dashboard". Use o botão de olho pra ocultar um card de indicador ou uma observação — o que estiver oculto aqui não aparece no relatório. Pré-visualização do que vai para o relatório gerado pelo botão "Gerar Relatório". Use o botão de olho pra ocultar um card de indicador ou uma observação — o que estiver oculto aqui não aparece no relatório.
</p> </p>
<section class="dc-dash-section"> <section class="dc-dash-section">
@ -589,8 +589,8 @@
<div class="dc-dash-resumo-fechamento"> <div class="dc-dash-resumo-fechamento">
<h4 class="dc-dash-resumo-fechamento__titulo">Resumo do Fechamento</h4> <h4 class="dc-dash-resumo-fechamento__titulo">Resumo do Fechamento</h4>
<p class="dc-dash-resumo-fechamento__dica">Escreva aqui as considerações e análises deste fechamento — cole (Ctrl+V) ou arraste uma imagem pra incluí-la no texto. Aparece no relatório "Gerar Dashboard", antes dos indicadores.</p> <p class="dc-dash-resumo-fechamento__dica">Escreva aqui as considerações e análises deste fechamento — cole (Ctrl+V) ou arraste uma imagem pra incluí-la no texto. Aparece no relatório "Gerar Relatório", antes dos indicadores.</p>
<div id="dc-dash-resumo-fechamento-editor" class="dc-dash-richtext" contenteditable="true" data-placeholder="Ex.: A escrituração contábil da empresa foi atualizada até .../.../.... Considerações: o resultado do exercício foi..."></div> <div id="dc-dash-resumo-fechamento-editor" class="dc-dash-richtext" contenteditable="true" data-placeholder="Ex.: A escrituração contábil da empresa foi atualizada até …/…/…. Considerações: o resultado do exercício foi…"></div>
<div class="dc-dash-resumo-fechamento__acoes"> <div class="dc-dash-resumo-fechamento__acoes">
<button type="button" class="btn-solid" id="dc-dash-resumo-fechamento-salvar-btn">Salvar</button> <button type="button" class="btn-solid" id="dc-dash-resumo-fechamento-salvar-btn">Salvar</button>
<span class="dc-dash-resumo-fechamento__status" id="dc-dash-resumo-fechamento-status" hidden></span> <span class="dc-dash-resumo-fechamento__status" id="dc-dash-resumo-fechamento-status" hidden></span>
@ -631,7 +631,7 @@
</label> </label>
<input type="file" id="dc-reprocessar-arquivo" accept=".pdf" class="dc-file-field__input" /> <input type="file" id="dc-reprocessar-arquivo" accept=".pdf" class="dc-file-field__input" />
<span class="dc-file-field__name" id="dc-reprocessar-arquivo-name">Nenhum arquivo selecionado</span> <span class="dc-file-field__name" id="dc-reprocessar-arquivo-name">Nenhum arquivo selecionado</span>
<button type="button" class="icon-btn dc-file-field__remove" id="dc-reprocessar-arquivo-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden> <button type="button" class="icon-btn dc-file-field__remove" id="dc-reprocessar-arquivo-remove" aria-label="Remover arquivo" data-dc-dica="Remover arquivo" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
</button> </button>
</div> </div>
@ -643,7 +643,7 @@
<p class="modal-error" id="dc-reprocessar-error"></p> <p class="modal-error" id="dc-reprocessar-error"></p>
<div class="dc-progress" id="dc-reprocessar-progress" hidden> <div class="dc-progress" id="dc-reprocessar-progress" hidden>
<div class="dc-progress__bar"><div class="dc-progress__fill"></div></div> <div class="dc-progress__bar"><div class="dc-progress__fill"></div></div>
<p class="dc-progress__label">Processando o arquivo, aguarde...</p> <p class="dc-progress__label">Processando o arquivo, aguarde…</p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn-outline" id="dc-reprocessar-cancel-btn">Cancelar</button> <button type="button" class="btn-outline" id="dc-reprocessar-cancel-btn">Cancelar</button>
@ -658,7 +658,7 @@
<p class="modal-card__subtitle" id="dc-achado-modal-mensagem"></p> <p class="modal-card__subtitle" id="dc-achado-modal-mensagem"></p>
<div class="modal-field"> <div class="modal-field">
<label for="dc-achado-modal-texto">Observação (obrigatória)</label> <label for="dc-achado-modal-texto">Observação (obrigatória)</label>
<textarea id="dc-achado-modal-texto" rows="5" placeholder="Justifique o tratamento desta observação..."></textarea> <textarea id="dc-achado-modal-texto" rows="5" placeholder="Justifique o tratamento desta observação…"></textarea>
</div> </div>
<p class="modal-error" id="dc-achado-modal-error"></p> <p class="modal-error" id="dc-achado-modal-error"></p>
<div class="modal-actions"> <div class="modal-actions">
@ -725,12 +725,12 @@
<div class="modal-field"> <div class="modal-field">
<label for="dc-indicador-modal-nome">Nome</label> <label for="dc-indicador-modal-nome">Nome</label>
<input type="text" id="dc-indicador-modal-nome" placeholder="ex.: Margem Líquida" /> <input type="text" id="dc-indicador-modal-nome" name="indicador_nome" autocomplete="off" placeholder="ex.: Margem Líquida…" />
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="dc-indicador-modal-descricao">Descrição</label> <label for="dc-indicador-modal-descricao">Descrição</label>
<textarea id="dc-indicador-modal-descricao" rows="3" placeholder="O que este indicador mede, como interpretar o resultado..."></textarea> <textarea id="dc-indicador-modal-descricao" rows="3" placeholder="O que este indicador mede, como interpretar o resultado…"></textarea>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="dc-indicador-modal-formato">Formato de exibição</label> <label for="dc-indicador-modal-formato">Formato de exibição</label>
@ -748,7 +748,7 @@
</label> </label>
<div class="modal-field"> <div class="modal-field">
<label>Ícone (aparece no card do relatório "Gerar Dashboard")</label> <label>Ícone (aparece no card do relatório "Gerar Relatório")</label>
<div class="dc-ind-icone-grid" id="dc-indicador-icones"></div> <div class="dc-ind-icone-grid" id="dc-indicador-icones"></div>
</div> </div>
@ -760,14 +760,14 @@
<div class="modal-field"> <div class="modal-field">
<label for="dc-indicador-modal-formula">Fórmula (cálculo interno)</label> <label for="dc-indicador-modal-formula">Fórmula (cálculo interno)</label>
<input type="text" id="dc-indicador-modal-formula" placeholder="ex.: (receita - custo) / receita" /> <input type="text" id="dc-indicador-modal-formula" name="indicador_formula" autocomplete="off" placeholder="ex.: (receita - custo) / receita…" />
<p class="dc-ind-formula-hint" id="dc-indicador-modal-formula-hint">Use a chave de cada componente acima, combinadas com +, -, * , / e parênteses.</p> <p class="dc-ind-formula-hint" id="dc-indicador-modal-formula-hint">Use a chave de cada componente acima, combinadas com +, -, * , / e parênteses.</p>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="dc-indicador-modal-formula-exibicao">Fórmula (como aparece ao cliente)</label> <label for="dc-indicador-modal-formula-exibicao">Fórmula (como aparece ao cliente)</label>
<input type="text" id="dc-indicador-modal-formula-exibicao" placeholder="ex.: Lucro Líquido − Despesas Financeiras" /> <input type="text" id="dc-indicador-modal-formula-exibicao" name="indicador_formula_exibicao" autocomplete="off" placeholder="ex.: Lucro Líquido − Despesas Financeiras…" />
<p class="dc-ind-formula-hint">Texto livre, só pra exibição no relatório "Gerar Dashboard" — não precisa (e não deve) usar as chaves técnicas dos componentes. Se deixar em branco, o relatório mostra a fórmula de cálculo interno acima.</p> <p class="dc-ind-formula-hint">Texto livre, só pra exibição no relatório "Gerar Relatório" — não precisa (e não deve) usar as chaves técnicas dos componentes. Se deixar em branco, o relatório mostra a fórmula de cálculo interno acima.</p>
</div> </div>
<button type="button" class="btn-outline" id="dc-indicador-calcular-btn">Calcular com esta apuração</button> <button type="button" class="btn-outline" id="dc-indicador-calcular-btn">Calcular com esta apuração</button>
@ -890,7 +890,7 @@
</div> </div>
</div> </div>
<button type="button" class="dc-back-to-top-btn" id="dc-back-to-top-btn" aria-label="Voltar ao início da página" title="Voltar ao início da página" hidden> <button type="button" class="dc-back-to-top-btn" id="dc-back-to-top-btn" aria-label="Voltar ao início da página" data-dc-dica="Voltar ao início da página" hidden>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
</button> </button>

View File

@ -400,7 +400,7 @@
<label for="ips-imp-empresa-search">Empresa</label> <label for="ips-imp-empresa-search">Empresa</label>
<p class="ips-regra-actions__hint">Só aparecem empresas com regra de custeio já cadastrada — cadastre ou edite uma regra em "Cadastro de Regras" antes de importar uma empresa nova.</p> <p class="ips-regra-actions__hint">Só aparecem empresas com regra de custeio já cadastrada — cadastre ou edite uma regra em "Cadastro de Regras" antes de importar uma empresa nova.</p>
<div class="ips-combo" id="ips-imp-empresa-combo"> <div class="ips-combo" id="ips-imp-empresa-combo">
<input type="text" id="ips-imp-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa..." autocomplete="off" /> <input type="text" id="ips-imp-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa…" autocomplete="off" />
<input type="hidden" id="ips-imp-empresa-valor" /> <input type="hidden" id="ips-imp-empresa-valor" />
<button type="button" class="ips-combo__clear" id="ips-imp-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-imp-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -414,7 +414,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ips-imp-operadora-search">Operadora</label> <label for="ips-imp-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-imp-operadora-combo"> <div class="ips-combo" id="ips-imp-operadora-combo">
<input type="text" id="ips-imp-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro..." autocomplete="off" disabled /> <input type="text" id="ips-imp-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro…" autocomplete="off" disabled />
<input type="hidden" id="ips-form-operadora" /> <input type="hidden" id="ips-form-operadora" />
<button type="button" class="ips-combo__clear" id="ips-imp-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-imp-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -521,7 +521,7 @@
<p class="ips-vincular-resumo" id="ips-vincular-resumo"></p> <p class="ips-vincular-resumo" id="ips-vincular-resumo"></p>
<div class="modal-field"> <div class="modal-field">
<label for="ips-vincular-search">Buscar na planilha padrão</label> <label for="ips-vincular-search">Buscar na planilha padrão</label>
<input type="text" id="ips-vincular-search" class="checklist-search" placeholder="Buscar por nome..." autocomplete="off" /> <input type="text" id="ips-vincular-search" class="checklist-search" placeholder="Buscar por nome…" autocomplete="off" />
</div> </div>
<div class="checklist-box" id="ips-vincular-lista"></div> <div class="checklist-box" id="ips-vincular-lista"></div>
<p class="modal-error" id="ips-vincular-error"></p> <p class="modal-error" id="ips-vincular-error"></p>
@ -554,7 +554,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-empresa-search">Empresa</label> <label for="ips-regracad-empresa-search">Empresa</label>
<div class="ips-combo" id="ips-regracad-empresa-combo"> <div class="ips-combo" id="ips-regracad-empresa-combo">
<input type="text" id="ips-regracad-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa..." autocomplete="off" /> <input type="text" id="ips-regracad-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa…" autocomplete="off" />
<input type="hidden" id="ips-regracad-empresa-valor" /> <input type="hidden" id="ips-regracad-empresa-valor" />
<button type="button" class="ips-combo__clear" id="ips-regracad-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-regracad-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -571,7 +571,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-operadora-search">Operadora</label> <label for="ips-regracad-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-regracad-operadora-combo"> <div class="ips-combo" id="ips-regracad-operadora-combo">
<input type="text" id="ips-regracad-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro..." autocomplete="off" disabled /> <input type="text" id="ips-regracad-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro…" autocomplete="off" disabled />
<input type="hidden" id="ips-regracad-operadora-valor" /> <input type="hidden" id="ips-regracad-operadora-valor" />
<button type="button" class="ips-combo__clear" id="ips-regracad-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-regracad-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -594,14 +594,14 @@
<div class="ips-regracad-novo__empresa-row"> <div class="ips-regracad-novo__empresa-row">
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-novo-codigo-empresa">Código da empresa</label> <label for="ips-regracad-novo-codigo-empresa">Código da empresa</label>
<input type="text" id="ips-regracad-novo-codigo-empresa" placeholder="Ex.: 92" autocomplete="off" /> <input type="text" id="ips-regracad-novo-codigo-empresa" placeholder="Ex.: 92" inputmode="numeric" spellcheck="false" autocomplete="off" />
</div> </div>
<p class="ips-regracad-novo__empresa-nome" id="ips-regracad-novo-empresa-nome" hidden></p> <p class="ips-regracad-novo__empresa-nome" id="ips-regracad-novo-empresa-nome" hidden></p>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-novo-operadora-search">Operadora</label> <label for="ips-regracad-novo-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-regracad-novo-operadora-combo"> <div class="ips-combo" id="ips-regracad-novo-operadora-combo">
<input type="text" id="ips-regracad-novo-operadora-search" class="ips-combo__input" placeholder="Selecionar operadora..." autocomplete="off" /> <input type="text" id="ips-regracad-novo-operadora-search" class="ips-combo__input" placeholder="Selecionar operadora…" autocomplete="off" />
<input type="hidden" id="ips-regracad-novo-operadora-valor" /> <input type="hidden" id="ips-regracad-novo-operadora-valor" />
<button type="button" class="ips-combo__toggle" id="ips-regracad-novo-operadora-toggle-btn" aria-label="Ver todas as operadoras" title="Ver todas as operadoras"> <button type="button" class="ips-combo__toggle" id="ips-regracad-novo-operadora-toggle-btn" aria-label="Ver todas as operadoras" title="Ver todas as operadoras">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
@ -629,17 +629,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-titular" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-titular" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-mensalidade-titular">Limite de valor custeado pela empresa</label> <label for="ips-limite-mensalidade-titular">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-mensalidade-titular" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-mensalidade-titular" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-mensalidade-titular">% de custeio da empresa</label> <label for="ips-percentual-mensalidade-titular">% de custeio da empresa</label>
<input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-mensalidade-titular">Limite de desconto do empregado</label> <label for="ips-limite-desconto-mensalidade-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-titular" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-mensalidade-titular" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>
@ -653,17 +653,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-dependente" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-dependente" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-mensalidade-dependente">Limite de valor custeado pela empresa</label> <label for="ips-limite-mensalidade-dependente">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-mensalidade-dependente" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-mensalidade-dependente" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-mensalidade-dependente">% de custeio da empresa</label> <label for="ips-percentual-mensalidade-dependente">% de custeio da empresa</label>
<input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-mensalidade-dependente">Limite de desconto do empregado</label> <label for="ips-limite-desconto-mensalidade-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-dependente" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-mensalidade-dependente" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>
@ -685,17 +685,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-titular" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-titular" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-coparticipacao-titular">Limite de valor custeado pela empresa</label> <label for="ips-limite-coparticipacao-titular">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-coparticipacao-titular" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-coparticipacao-titular" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-coparticipacao-titular">% de custeio da empresa</label> <label for="ips-percentual-coparticipacao-titular">% de custeio da empresa</label>
<input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-titular">Limite de desconto do empregado</label> <label for="ips-limite-desconto-coparticipacao-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-titular" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-coparticipacao-titular" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>
@ -709,17 +709,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-dependente" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-dependente" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-coparticipacao-dependente">Limite de valor custeado pela empresa</label> <label for="ips-limite-coparticipacao-dependente">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-coparticipacao-dependente" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-coparticipacao-dependente" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-coparticipacao-dependente">% de custeio da empresa</label> <label for="ips-percentual-coparticipacao-dependente">% de custeio da empresa</label>
<input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-dependente">Limite de desconto do empregado</label> <label for="ips-limite-desconto-coparticipacao-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-dependente" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-coparticipacao-dependente" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>

View File

@ -400,7 +400,7 @@
<label for="ips-imp-empresa-search">Empresa</label> <label for="ips-imp-empresa-search">Empresa</label>
<p class="ips-regra-actions__hint">Só aparecem empresas com regra de custeio já cadastrada — cadastre ou edite uma regra em "Cadastro de Regras" antes de importar uma empresa nova.</p> <p class="ips-regra-actions__hint">Só aparecem empresas com regra de custeio já cadastrada — cadastre ou edite uma regra em "Cadastro de Regras" antes de importar uma empresa nova.</p>
<div class="ips-combo" id="ips-imp-empresa-combo"> <div class="ips-combo" id="ips-imp-empresa-combo">
<input type="text" id="ips-imp-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa..." autocomplete="off" /> <input type="text" id="ips-imp-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa…" autocomplete="off" />
<input type="hidden" id="ips-imp-empresa-valor" /> <input type="hidden" id="ips-imp-empresa-valor" />
<button type="button" class="ips-combo__clear" id="ips-imp-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-imp-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -414,7 +414,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ips-imp-operadora-search">Operadora</label> <label for="ips-imp-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-imp-operadora-combo"> <div class="ips-combo" id="ips-imp-operadora-combo">
<input type="text" id="ips-imp-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro..." autocomplete="off" disabled /> <input type="text" id="ips-imp-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro…" autocomplete="off" disabled />
<input type="hidden" id="ips-form-operadora" /> <input type="hidden" id="ips-form-operadora" />
<button type="button" class="ips-combo__clear" id="ips-imp-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-imp-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -524,7 +524,7 @@
<p class="ips-vincular-resumo" id="ips-vincular-resumo"></p> <p class="ips-vincular-resumo" id="ips-vincular-resumo"></p>
<div class="modal-field"> <div class="modal-field">
<label for="ips-vincular-search">Buscar na planilha padrão</label> <label for="ips-vincular-search">Buscar na planilha padrão</label>
<input type="text" id="ips-vincular-search" class="checklist-search" placeholder="Buscar por nome..." autocomplete="off" /> <input type="text" id="ips-vincular-search" class="checklist-search" placeholder="Buscar por nome…" autocomplete="off" />
</div> </div>
<div class="checklist-box" id="ips-vincular-lista"></div> <div class="checklist-box" id="ips-vincular-lista"></div>
<p class="modal-error" id="ips-vincular-error"></p> <p class="modal-error" id="ips-vincular-error"></p>
@ -557,7 +557,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-empresa-search">Empresa</label> <label for="ips-regracad-empresa-search">Empresa</label>
<div class="ips-combo" id="ips-regracad-empresa-combo"> <div class="ips-combo" id="ips-regracad-empresa-combo">
<input type="text" id="ips-regracad-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa..." autocomplete="off" /> <input type="text" id="ips-regracad-empresa-search" class="ips-combo__input" placeholder="Selecionar empresa…" autocomplete="off" />
<input type="hidden" id="ips-regracad-empresa-valor" /> <input type="hidden" id="ips-regracad-empresa-valor" />
<button type="button" class="ips-combo__clear" id="ips-regracad-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-regracad-empresa-limpar-btn" aria-label="Limpar seleção de empresa" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -574,7 +574,7 @@
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-operadora-search">Operadora</label> <label for="ips-regracad-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-regracad-operadora-combo"> <div class="ips-combo" id="ips-regracad-operadora-combo">
<input type="text" id="ips-regracad-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro..." autocomplete="off" disabled /> <input type="text" id="ips-regracad-operadora-search" class="ips-combo__input" placeholder="Selecione a empresa primeiro…" autocomplete="off" disabled />
<input type="hidden" id="ips-regracad-operadora-valor" /> <input type="hidden" id="ips-regracad-operadora-valor" />
<button type="button" class="ips-combo__clear" id="ips-regracad-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden> <button type="button" class="ips-combo__clear" id="ips-regracad-operadora-limpar-btn" aria-label="Limpar seleção de operadora" title="Limpar seleção" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -597,14 +597,14 @@
<div class="ips-regracad-novo__empresa-row"> <div class="ips-regracad-novo__empresa-row">
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-novo-codigo-empresa">Código da empresa</label> <label for="ips-regracad-novo-codigo-empresa">Código da empresa</label>
<input type="text" id="ips-regracad-novo-codigo-empresa" placeholder="Ex.: 92" autocomplete="off" /> <input type="text" id="ips-regracad-novo-codigo-empresa" placeholder="Ex.: 92" inputmode="numeric" spellcheck="false" autocomplete="off" />
</div> </div>
<p class="ips-regracad-novo__empresa-nome" id="ips-regracad-novo-empresa-nome" hidden></p> <p class="ips-regracad-novo__empresa-nome" id="ips-regracad-novo-empresa-nome" hidden></p>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-regracad-novo-operadora-search">Operadora</label> <label for="ips-regracad-novo-operadora-search">Operadora</label>
<div class="ips-combo" id="ips-regracad-novo-operadora-combo"> <div class="ips-combo" id="ips-regracad-novo-operadora-combo">
<input type="text" id="ips-regracad-novo-operadora-search" class="ips-combo__input" placeholder="Selecionar operadora..." autocomplete="off" /> <input type="text" id="ips-regracad-novo-operadora-search" class="ips-combo__input" placeholder="Selecionar operadora…" autocomplete="off" />
<input type="hidden" id="ips-regracad-novo-operadora-valor" /> <input type="hidden" id="ips-regracad-novo-operadora-valor" />
<button type="button" class="ips-combo__toggle" id="ips-regracad-novo-operadora-toggle-btn" aria-label="Ver todas as operadoras" title="Ver todas as operadoras"> <button type="button" class="ips-combo__toggle" id="ips-regracad-novo-operadora-toggle-btn" aria-label="Ver todas as operadoras" title="Ver todas as operadoras">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
@ -632,17 +632,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-titular" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-titular" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-mensalidade-titular">Limite de valor custeado pela empresa</label> <label for="ips-limite-mensalidade-titular">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-mensalidade-titular" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-mensalidade-titular" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-mensalidade-titular">% de custeio da empresa</label> <label for="ips-percentual-mensalidade-titular">% de custeio da empresa</label>
<input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-mensalidade-titular" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-mensalidade-titular">Limite de desconto do empregado</label> <label for="ips-limite-desconto-mensalidade-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-titular" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-mensalidade-titular" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>
@ -656,17 +656,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-dependente" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-mensalidade-dependente" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-mensalidade-dependente">Limite de valor custeado pela empresa</label> <label for="ips-limite-mensalidade-dependente">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-mensalidade-dependente" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-mensalidade-dependente" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-mensalidade-dependente">% de custeio da empresa</label> <label for="ips-percentual-mensalidade-dependente">% de custeio da empresa</label>
<input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-mensalidade-dependente" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-mensalidade-dependente">Limite de desconto do empregado</label> <label for="ips-limite-desconto-mensalidade-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-mensalidade-dependente" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-mensalidade-dependente" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>
@ -688,17 +688,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-titular" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-titular" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-coparticipacao-titular">Limite de valor custeado pela empresa</label> <label for="ips-limite-coparticipacao-titular">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-coparticipacao-titular" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-coparticipacao-titular" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-coparticipacao-titular">% de custeio da empresa</label> <label for="ips-percentual-coparticipacao-titular">% de custeio da empresa</label>
<input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-coparticipacao-titular" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-titular">Limite de desconto do empregado</label> <label for="ips-limite-desconto-coparticipacao-titular">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-titular" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-coparticipacao-titular" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>
@ -712,17 +712,17 @@
<div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-dependente" hidden> <div class="ips-regra-especifica" id="ips-regra-especifica-coparticipacao-dependente" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-coparticipacao-dependente">Limite de valor custeado pela empresa</label> <label for="ips-limite-coparticipacao-dependente">Limite de valor custeado pela empresa</label>
<input type="text" id="ips-limite-coparticipacao-dependente" placeholder="Ex.: 150,00" autocomplete="off" /> <input type="text" id="ips-limite-coparticipacao-dependente" placeholder="Ex.: 150,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ips-percentual-coparticipacao-dependente">% de custeio da empresa</label> <label for="ips-percentual-coparticipacao-dependente">% de custeio da empresa</label>
<input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" autocomplete="off" /> <input type="text" id="ips-percentual-coparticipacao-dependente" placeholder="Ex.: 50" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p> <p class="ips-regra-especifica__hint">Preencha um dos dois campos ou os dois — se os dois forem preenchidos, vale o que resultar no menor valor custeado pela empresa. O excedente/restante vira desconto do empregado.</p>
<div class="ips-regra-especifica__alt"> <div class="ips-regra-especifica__alt">
<div class="modal-field"> <div class="modal-field">
<label for="ips-limite-desconto-coparticipacao-dependente">Limite de desconto do empregado</label> <label for="ips-limite-desconto-coparticipacao-dependente">Limite de desconto do empregado</label>
<input type="text" id="ips-limite-desconto-coparticipacao-dependente" placeholder="Ex.: 10,00" autocomplete="off" /> <input type="text" id="ips-limite-desconto-coparticipacao-dependente" placeholder="Ex.: 10,00" inputmode="decimal" autocomplete="off" />
</div> </div>
<p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p> <p class="ips-regra-especifica__hint">Alternativa aos campos acima: define o valor máximo descontado do empregado — a empresa custeia o restante. Não pode ser combinado com o limite de valor/percentual (preencher este campo desabilita os outros dois, e vice-versa).</p>
</div> </div>

View File

@ -52,7 +52,7 @@
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" stroke-linecap="round" stroke-linejoin="round"/> <path d="M4 20c0-4 4-6 8-6s8 2 8 6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="8" r="4"/> <circle cx="12" cy="8" r="4"/>
</svg> </svg>
<input id="login-user" name="user" type="text" autocomplete="username" placeholder="Digite seu login" /> <input id="login-user" name="user" type="text" autocomplete="username" spellcheck="false" autocapitalize="none" placeholder="Digite seu login…" />
</div> </div>
</div> </div>
@ -63,7 +63,7 @@
<rect x="4" y="10" width="16" height="10" rx="2"/> <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"/> <path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
<input id="login-pass" name="pass" type="password" autocomplete="current-password" placeholder="Digite sua senha" /> <input id="login-pass" name="pass" type="password" autocomplete="current-password" placeholder="Digite sua senha…" />
<button type="button" id="toggle-password" class="field__toggle" aria-label="Mostrar senha"> <button type="button" id="toggle-password" class="field__toggle" aria-label="Mostrar senha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/>
@ -84,7 +84,7 @@
</form> </form>
<p class="login-help"> <p class="login-help">
Esqueceu sua senha? Contate a <a href="#">Inovação</a> Esqueceu sua senha? Contate a <strong class="login-help__destaque">Inovação</strong>
</p> </p>
</div> </div>
</div> </div>

View File

@ -376,7 +376,7 @@
<div class="ind-form"> <div class="ind-form">
<div class="modal-field"> <div class="modal-field">
<label for="ind-form-competencia">Competência</label> <label for="ind-form-competencia">Competência</label>
<input type="text" id="ind-form-competencia" inputmode="numeric" placeholder="MM/AAAA" maxlength="7" autocomplete="off" /> <input type="text" id="ind-form-competencia" name="competencia" inputmode="numeric" placeholder="Ex.: 08/2026…" maxlength="7" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label>Planilha de Serviços Tareffa (.xlsx)</label> <label>Planilha de Serviços Tareffa (.xlsx)</label>
@ -385,7 +385,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo Selecionar arquivo
</label> </label>
<input type="file" id="ind-form-tareffa" accept=".xlsx" class="ind-file-field__input" /> <input type="file" id="ind-form-tareffa" name="planilha_tareffa" accept=".xlsx" class="ind-file-field__input" />
<span class="ind-file-field__name" id="ind-form-tareffa-name">Nenhum arquivo selecionado</span> <span class="ind-file-field__name" id="ind-form-tareffa-name">Nenhum arquivo selecionado</span>
<button type="button" class="icon-btn ind-file-field__remove" id="ind-form-tareffa-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden> <button type="button" class="icon-btn ind-file-field__remove" id="ind-form-tareffa-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -399,7 +399,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo Selecionar arquivo
</label> </label>
<input type="file" id="ind-form-honorarios" accept=".xlsx" class="ind-file-field__input" /> <input type="file" id="ind-form-honorarios" name="planilha_honorarios" accept=".xlsx" class="ind-file-field__input" />
<span class="ind-file-field__name" id="ind-form-honorarios-name">Nenhum arquivo selecionado</span> <span class="ind-file-field__name" id="ind-form-honorarios-name">Nenhum arquivo selecionado</span>
<button type="button" class="icon-btn ind-file-field__remove" id="ind-form-honorarios-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden> <button type="button" class="icon-btn ind-file-field__remove" id="ind-form-honorarios-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
@ -568,7 +568,7 @@
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ind-criterio-nome">Nome</label> <label for="ind-criterio-nome">Nome</label>
<input type="text" id="ind-criterio-nome" autocomplete="off" /> <input type="text" id="ind-criterio-nome" name="nome" autocomplete="off" />
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
@ -581,7 +581,7 @@
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ind-criterio-peso">Peso (0 a 100)</label> <label for="ind-criterio-peso">Peso (0 a 100)</label>
<input type="text" id="ind-criterio-peso" placeholder="Ex.: 60" autocomplete="off" /> <input type="text" id="ind-criterio-peso" name="peso" inputmode="decimal" placeholder="Ex.: 60" autocomplete="off" />
</div> </div>
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
@ -617,7 +617,7 @@
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ind-criterio-limiar">Limiar para "Sim" (%)</label> <label for="ind-criterio-limiar">Limiar para "Sim" (%)</label>
<input type="text" id="ind-criterio-limiar" placeholder="Ex.: 90" autocomplete="off" /> <input type="text" id="ind-criterio-limiar" name="limiar_percentual" inputmode="decimal" placeholder="Ex.: 90" autocomplete="off" />
</div> </div>
</div> </div>
<label class="modal-checkbox"> <label class="modal-checkbox">
@ -638,7 +638,7 @@
<p class="ind-subtitle">Selecione os colaboradores e clique em "Sim" pra considerar o Indicador Individual atingido (100%) pra todos de uma vez (ex.: a diretoria decide que todo mundo recebe integral, mesmo quem não bateu a meta individual), ou em "Reverter" pra desfazer qualquer ajuste manual anterior e voltar ao cálculo automático. Grupo e Departamento são ajustados na tela de Metas, no início da revisão.</p> <p class="ind-subtitle">Selecione os colaboradores e clique em "Sim" pra considerar o Indicador Individual atingido (100%) pra todos de uma vez (ex.: a diretoria decide que todo mundo recebe integral, mesmo quem não bateu a meta individual), ou em "Reverter" pra desfazer qualquer ajuste manual anterior e voltar ao cálculo automático. Grupo e Departamento são ajustados na tela de Metas, no início da revisão.</p>
<div class="modal-field"> <div class="modal-field">
<label>Colaboradores</label> <label>Colaboradores</label>
<input type="text" id="ind-lote-global-search" class="checklist-search" placeholder="Buscar por nome..." autocomplete="off" /> <input type="text" id="ind-lote-global-search" name="busca" class="checklist-search" placeholder="Buscar por nome…" autocomplete="off" spellcheck="false" />
<select id="ind-lote-global-departamento" class="ind-checklist-departamento-select"></select> <select id="ind-lote-global-departamento" class="ind-checklist-departamento-select"></select>
<label class="modal-checkbox checklist-select-all"> <label class="modal-checkbox checklist-select-all">
<input type="checkbox" id="ind-lote-global-select-all" /> <input type="checkbox" id="ind-lote-global-select-all" />
@ -661,7 +661,7 @@
<p class="ind-subtitle">Escolha quem recebe recibo desta vez — um colaborador só, alguns específicos, todos de um departamento, ou todo mundo (já vem tudo marcado). Gerar não reprocessa nada, só formata o PDF com o que já está salvo.</p> <p class="ind-subtitle">Escolha quem recebe recibo desta vez — um colaborador só, alguns específicos, todos de um departamento, ou todo mundo (já vem tudo marcado). Gerar não reprocessa nada, só formata o PDF com o que já está salvo.</p>
<div class="modal-field"> <div class="modal-field">
<label>Colaboradores</label> <label>Colaboradores</label>
<input type="text" id="ind-gerar-recibos-search" class="checklist-search" placeholder="Buscar por nome..." autocomplete="off" /> <input type="text" id="ind-gerar-recibos-search" name="busca" class="checklist-search" placeholder="Buscar por nome…" autocomplete="off" spellcheck="false" />
<select id="ind-gerar-recibos-departamento" class="ind-checklist-departamento-select"></select> <select id="ind-gerar-recibos-departamento" class="ind-checklist-departamento-select"></select>
<label class="modal-checkbox checklist-select-all"> <label class="modal-checkbox checklist-select-all">
<input type="checkbox" id="ind-gerar-recibos-select-all" /> <input type="checkbox" id="ind-gerar-recibos-select-all" />
@ -706,7 +706,7 @@
<h2 class="modal-card__title">Corrigir Responsável</h2> <h2 class="modal-card__title">Corrigir Responsável</h2>
<div class="ind-esh-scroll"> <div class="ind-esh-scroll">
<p class="ind-subtitle">Busque uma empresa por nome ou código pra ver quem é responsável por cada papel dela e trocar, se precisar (ex.: o "Fiscal" de uma empresa deveria ser outro colaborador).</p> <p class="ind-subtitle">Busque uma empresa por nome ou código pra ver quem é responsável por cada papel dela e trocar, se precisar (ex.: o "Fiscal" de uma empresa deveria ser outro colaborador).</p>
<input type="text" id="ind-corrigir-empresa-search" class="checklist-search" placeholder="Buscar empresa por nome ou código..." autocomplete="off" /> <input type="text" id="ind-corrigir-empresa-search" class="checklist-search" placeholder="Buscar empresa por nome ou código…" autocomplete="off" spellcheck="false" />
<div class="checklist-box" id="ind-corrigir-empresa-resultados" hidden></div> <div class="checklist-box" id="ind-corrigir-empresa-resultados" hidden></div>
<div id="ind-corrigir-empresa-detalhe"></div> <div id="ind-corrigir-empresa-detalhe"></div>
</div> </div>
@ -723,7 +723,7 @@
<p class="ind-subtitle" id="ind-ajuste-pct-subtitle">O valor informado passa a valer no cálculo do recibo até você reverter (botão "Recalcular automaticamente").</p> <p class="ind-subtitle" id="ind-ajuste-pct-subtitle">O valor informado passa a valer no cálculo do recibo até você reverter (botão "Recalcular automaticamente").</p>
<div class="modal-field"> <div class="modal-field">
<label for="ind-ajuste-pct-valor">Percentual (0 a 100)</label> <label for="ind-ajuste-pct-valor">Percentual (0 a 100)</label>
<input type="text" id="ind-ajuste-pct-valor" placeholder="Ex.: 85" autocomplete="off" /> <input type="text" id="ind-ajuste-pct-valor" name="pct_individual" inputmode="decimal" placeholder="Ex.: 85" autocomplete="off" />
</div> </div>
<p class="modal-error" id="ind-ajuste-pct-error"></p> <p class="modal-error" id="ind-ajuste-pct-error"></p>
<div class="modal-actions"> <div class="modal-actions">
@ -753,20 +753,20 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="ind-percentual-individual">% Individual</label> <label for="ind-percentual-individual">% Individual</label>
<input type="text" id="ind-percentual-individual" placeholder="Ex.: 1,42" autocomplete="off" /> <input type="text" id="ind-percentual-individual" name="percentual_individual" inputmode="decimal" placeholder="Ex.: 1,42" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ind-percentual-grupo">% Grupo</label> <label for="ind-percentual-grupo">% Grupo</label>
<input type="text" id="ind-percentual-grupo" placeholder="Ex.: 20" autocomplete="off" /> <input type="text" id="ind-percentual-grupo" name="percentual_grupo" inputmode="decimal" placeholder="Ex.: 20" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ind-percentual-departamento-valor">% Departamento</label> <label for="ind-percentual-departamento-valor">% Departamento</label>
<input type="text" id="ind-percentual-departamento-valor" placeholder="Ex.: 20" autocomplete="off" /> <input type="text" id="ind-percentual-departamento-valor" name="percentual_departamento" inputmode="decimal" placeholder="Ex.: 20" autocomplete="off" />
</div> </div>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ind-percentual-vigente">Vigente desde</label> <label for="ind-percentual-vigente">Vigente desde</label>
<input type="date" id="ind-percentual-vigente" /> <input type="date" id="ind-percentual-vigente" name="vigente_desde" />
</div> </div>
<p class="modal-error" id="ind-percentual-error"></p> <p class="modal-error" id="ind-percentual-error"></p>
<div class="modal-actions"> <div class="modal-actions">
@ -781,7 +781,7 @@
<h2 class="modal-card__title" id="ind-departamento-modal-title">Adicionar Departamento</h2> <h2 class="modal-card__title" id="ind-departamento-modal-title">Adicionar Departamento</h2>
<div class="modal-field"> <div class="modal-field">
<label for="ind-departamento-nome">Nome</label> <label for="ind-departamento-nome">Nome</label>
<input type="text" id="ind-departamento-nome" placeholder="Ex.: Fisco/Contábil" autocomplete="off" /> <input type="text" id="ind-departamento-nome" name="nome" placeholder="Ex.: Fisco/Contábil" autocomplete="off" />
</div> </div>
<label class="modal-checkbox"> <label class="modal-checkbox">
<input type="checkbox" id="ind-departamento-ativo" checked /> <input type="checkbox" id="ind-departamento-ativo" checked />
@ -810,7 +810,7 @@
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="ind-departamento-gerente-novo">Adicionar outro gerente</label> <label for="ind-departamento-gerente-novo">Adicionar outro gerente</label>
<input type="text" id="ind-departamento-gerente-novo" placeholder="Nome do gerente" autocomplete="off" /> <input type="text" id="ind-departamento-gerente-novo" name="nome_gerente" placeholder="Nome completo, ex.: Maria da Silva…" autocomplete="off" spellcheck="false" />
</div> </div>
<button type="button" class="btn-solid ind-departamento-gerente-add-btn" id="ind-departamento-gerente-add-btn">Adicionar</button> <button type="button" class="btn-solid ind-departamento-gerente-add-btn" id="ind-departamento-gerente-add-btn">Adicionar</button>
</div> </div>

View File

@ -364,10 +364,10 @@
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="lf-form-url">Link</label> <label for="lf-form-url">Link</label>
<input type="url" id="lf-form-url" placeholder="https://" autocomplete="off" /> <input type="url" id="lf-form-url" placeholder="https://" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="lf-form-icone" id="lf-form-icone-label">Ícone (opcional, até 2MB)</label> <label for="lf-form-icone" id="lf-form-icone-label">Ícone (opcional, até 2&nbsp;MB)</label>
<input type="file" id="lf-form-icone" accept="image/*" /> <input type="file" id="lf-form-icone" accept="image/*" />
</div> </div>
<p class="modal-error" id="lf-form-error"></p> <p class="modal-error" id="lf-form-error"></p>
@ -399,7 +399,7 @@
registros registros
</label> </label>
<div class="ram-lookup-search"> <div class="ram-lookup-search">
<input type="text" id="ramais-lookup-search" placeholder="Pesquisar por nome, departamento ou ramal..." autocomplete="off" /> <input type="text" id="ramais-lookup-search" placeholder="Pesquisar por nome, departamento ou ramal…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -425,7 +425,7 @@
</div> </div>
<div class="modal-actions modal-actions--spread"> <div class="modal-actions modal-actions--spread">
<button type="button" class="btn-outline" id="ramais-lookup-close-btn-2">Close</button> <button type="button" class="btn-outline" id="ramais-lookup-close-btn-2">Fechar</button>
<a href="ramais.html" class="btn-solid">Ir para Controle de Ramais</a> <a href="ramais.html" class="btn-solid">Ir para Controle de Ramais</a>
</div> </div>
</div> </div>

View File

@ -407,7 +407,7 @@
<div class="pa-tab-panel" data-ncf-panel="gestao" hidden> <div class="pa-tab-panel" data-ncf-panel="gestao" hidden>
<div class="ncf-gestao-toolbar"> <div class="ncf-gestao-toolbar">
<div class="ncf-gestao-toolbar__busca"> <div class="ncf-gestao-toolbar__busca">
<input type="search" id="ncf-gestao-search" class="ncf-gestao-search" placeholder="Buscar por colaborador, cliente/empresa ou assunto..." autocomplete="off" /> <input type="search" id="ncf-gestao-search" name="busca" class="ncf-gestao-search" placeholder="Buscar por colaborador, cliente/empresa ou assunto…" autocomplete="off" spellcheck="false" />
<button type="button" class="btn-outline" id="ncf-gestao-search-clear-btn" hidden>Limpar</button> <button type="button" class="btn-outline" id="ncf-gestao-search-clear-btn" hidden>Limpar</button>
</div> </div>
<div class="ncf-resumo-wrap"> <div class="ncf-resumo-wrap">
@ -478,11 +478,11 @@
<div class="ncf-col-filtro-popover" data-ncf-col-filtro-popover="vencimento" hidden> <div class="ncf-col-filtro-popover" data-ncf-col-filtro-popover="vencimento" hidden>
<div class="modal-field"> <div class="modal-field">
<label for="ncf-col-filtro-vencimento-de">De</label> <label for="ncf-col-filtro-vencimento-de">De</label>
<input type="date" id="ncf-col-filtro-vencimento-de" /> <input type="date" id="ncf-col-filtro-vencimento-de" name="vencimento_de" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ncf-col-filtro-vencimento-ate">Até</label> <label for="ncf-col-filtro-vencimento-ate">Até</label>
<input type="date" id="ncf-col-filtro-vencimento-ate" /> <input type="date" id="ncf-col-filtro-vencimento-ate" name="vencimento_ate" />
</div> </div>
<div class="ncf-col-filtro-popover__actions"> <div class="ncf-col-filtro-popover__actions">
<button type="button" class="btn-outline" data-ncf-col-filtro-limpar="vencimento">Limpar</button> <button type="button" class="btn-outline" data-ncf-col-filtro-limpar="vencimento">Limpar</button>
@ -588,7 +588,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo Selecionar arquivo
</label> </label>
<input type="file" id="ncf-form-ocorrencias" accept=".xlsx" class="ncf-file-field__input" /> <input type="file" id="ncf-form-ocorrencias" name="arquivo_ocorrencias" accept=".xlsx" class="ncf-file-field__input" />
<span class="ncf-file-field__name" id="ncf-form-ocorrencias-name">Nenhum arquivo selecionado</span> <span class="ncf-file-field__name" id="ncf-form-ocorrencias-name">Nenhum arquivo selecionado</span>
</div> </div>
</div> </div>
@ -599,7 +599,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Selecionar arquivo Selecionar arquivo
</label> </label>
<input type="file" id="ncf-form-acoes" accept=".xls" class="ncf-file-field__input" /> <input type="file" id="ncf-form-acoes" name="arquivo_acoes" accept=".xls" class="ncf-file-field__input" />
<span class="ncf-file-field__name" id="ncf-form-acoes-name">Nenhum arquivo selecionado</span> <span class="ncf-file-field__name" id="ncf-form-acoes-name">Nenhum arquivo selecionado</span>
</div> </div>
</div> </div>

View File

@ -328,7 +328,7 @@
<h2>Perfis de Acesso</h2> <h2>Perfis de Acesso</h2>
<div class="pa-toolbar"> <div class="pa-toolbar">
<div class="pa-search"> <div class="pa-search">
<input type="text" id="pa-search-input" placeholder="Pesquisar perfil..." autocomplete="off" /> <input type="text" id="pa-search-input" placeholder="Pesquisar perfil…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
<button type="button" class="btn-outline" id="pa-app-search-btn"> <button type="button" class="btn-outline" id="pa-app-search-btn">
@ -372,7 +372,7 @@
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="pa-nome">Nome do Perfil <span class="required">*</span></label> <label for="pa-nome">Nome do Perfil <span class="required">*</span></label>
<input type="text" id="pa-nome" placeholder="Ex.: Fiscal" /> <input type="text" id="pa-nome" placeholder="Ex.: Fiscal" autocomplete="off" />
<p class="pa-field-error" id="pa-nome-error"></p> <p class="pa-field-error" id="pa-nome-error"></p>
</div> </div>
</div> </div>
@ -380,7 +380,8 @@
<div class="pa-tabs"> <div class="pa-tabs">
<button type="button" class="pa-tab is-active" data-pa-tab="permissoes">Permissões</button> <button type="button" class="pa-tab is-active" data-pa-tab="permissoes">Permissões</button>
<button type="button" class="pa-tab" data-pa-tab="usuarios">Usuários do Escritório</button> <button type="button" class="pa-tab" data-pa-tab="usuarios" id="pa-tab-usuarios">Usuários do Escritório</button>
<span class="pa-tab-hint" id="pa-tab-usuarios-hint" hidden>Salve o perfil para vincular usuários.</span>
</div> </div>
<div class="pa-tab-panel" data-pa-tab-panel="permissoes"> <div class="pa-tab-panel" data-pa-tab-panel="permissoes">
@ -388,7 +389,7 @@
<div class="pa-module-list" id="pa-module-list"></div> <div class="pa-module-list" id="pa-module-list"></div>
<div class="pa-tree-panel"> <div class="pa-tree-panel">
<div class="pa-tree-search"> <div class="pa-tree-search">
<input type="text" id="pa-tree-search-input" placeholder="Pesquisar..." autocomplete="off" /> <input type="text" id="pa-tree-search-input" placeholder="Pesquisar…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
<div id="pa-tree-panel"></div> <div id="pa-tree-panel"></div>
@ -415,8 +416,8 @@
</tr> </tr>
<tr class="pa-users-table__filters"> <tr class="pa-users-table__filters">
<th></th> <th></th>
<th><input type="text" id="pa-users-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th> <th><input type="text" id="pa-users-available-search-nome" placeholder="Buscar por nome…" autocomplete="off" /></th>
<th><input type="text" id="pa-users-available-search-email" placeholder="Buscar por e-mail..." autocomplete="off" /></th> <th><input type="text" id="pa-users-available-search-email" placeholder="Buscar por e-mail…" autocomplete="off" /></th>
</tr> </tr>
</thead> </thead>
<tbody id="pa-users-available-body"></tbody> <tbody id="pa-users-available-body"></tbody>
@ -442,8 +443,8 @@
</tr> </tr>
<tr class="pa-users-table__filters"> <tr class="pa-users-table__filters">
<th></th> <th></th>
<th><input type="text" id="pa-users-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th> <th><input type="text" id="pa-users-linked-search-nome" placeholder="Buscar por nome…" autocomplete="off" /></th>
<th><input type="text" id="pa-users-linked-search-email" placeholder="Buscar por e-mail..." autocomplete="off" /></th> <th><input type="text" id="pa-users-linked-search-email" placeholder="Buscar por e-mail…" autocomplete="off" /></th>
</tr> </tr>
</thead> </thead>
<tbody id="pa-users-linked-body"></tbody> <tbody id="pa-users-linked-body"></tbody>
@ -579,7 +580,7 @@
<div class="modal-field" id="pa-app-search-field"> <div class="modal-field" id="pa-app-search-field">
<label for="pa-app-search-input">Aplicação</label> <label for="pa-app-search-input">Aplicação</label>
<input type="text" id="pa-app-search-input" placeholder="Buscar por nome..." autocomplete="off" /> <input type="text" id="pa-app-search-input" placeholder="Buscar por nome…" autocomplete="off" />
<div class="checklist-box" id="pa-app-search-results"></div> <div class="checklist-box" id="pa-app-search-results"></div>
</div> </div>
@ -589,7 +590,7 @@
Voltar à busca Voltar à busca
</button> </button>
<h3 class="pa-app-manage-title" id="pa-app-manage-title"></h3> <h3 class="pa-app-manage-title" id="pa-app-manage-title"></h3>
<input type="text" id="pa-app-manage-search" class="checklist-search" placeholder="Buscar perfil..." autocomplete="off" /> <input type="text" id="pa-app-manage-search" class="checklist-search" placeholder="Buscar perfil…" autocomplete="off" />
<div class="pa-table-wrap pa-app-manage-table-wrap"> <div class="pa-table-wrap pa-app-manage-table-wrap">
<table class="pa-table"> <table class="pa-table">
<thead id="pa-app-manage-thead"></thead> <thead id="pa-app-manage-thead"></thead>

View File

@ -340,7 +340,7 @@
<div id="dashboard-content"> <div id="dashboard-content">
<div class="app-search"> <div class="app-search">
<input type="text" id="app-search-input" placeholder="Pesquise por Aplicação..." autocomplete="off" /> <input type="text" id="app-search-input" placeholder="Pesquise por Aplicação…" autocomplete="off" />
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
@ -371,6 +371,9 @@
<h2 class="modal-card__title">Adicionar Widget</h2> <h2 class="modal-card__title">Adicionar Widget</h2>
<p class="modal-card__subtitle">Escolha um widget para exibir na tela inicial.</p> <p class="modal-card__subtitle">Escolha um widget para exibir na tela inicial.</p>
<div class="widget-picker-list" id="widget-picker-list"></div> <div class="widget-picker-list" id="widget-picker-list"></div>
<div class="modal-actions">
<button type="button" class="btn-outline" id="widget-picker-close-btn">Fechar</button>
</div>
</div> </div>
</div> </div>
@ -396,7 +399,7 @@
registros registros
</label> </label>
<div class="ram-lookup-search"> <div class="ram-lookup-search">
<input type="text" id="ramais-lookup-search" placeholder="Pesquisar por nome, departamento ou ramal..." autocomplete="off" /> <input type="text" id="ramais-lookup-search" placeholder="Pesquisar por nome, departamento ou ramal…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -451,8 +454,8 @@
</tr> </tr>
<tr class="dual-select__filters"> <tr class="dual-select__filters">
<th></th> <th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th> <th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome…" autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th> <th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento…" autocomplete="off" /></th>
</tr> </tr>
</thead> </thead>
<tbody id="manage-account-liderados-available-body"></tbody> <tbody id="manage-account-liderados-available-body"></tbody>
@ -475,8 +478,8 @@
</tr> </tr>
<tr class="dual-select__filters"> <tr class="dual-select__filters">
<th></th> <th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th> <th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome…" autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th> <th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento…" autocomplete="off" /></th>
</tr> </tr>
</thead> </thead>
<tbody id="manage-account-liderados-linked-body"></tbody> <tbody id="manage-account-liderados-linked-body"></tbody>
@ -497,24 +500,26 @@
<div class="modal-overlay" id="password-modal" hidden> <div class="modal-overlay" id="password-modal" hidden>
<div class="modal-card"> <div class="modal-card">
<h2 class="modal-card__title">Alterar senha</h2> <h2 class="modal-card__title">Alterar senha</h2>
<!-- Usuário oculto (preenchido por account.js): o gerenciador de senhas do navegador associa a senha nova à conta certa. -->
<input type="text" id="pwd-username" name="username" autocomplete="username" hidden />
<div class="modal-field"> <div class="modal-field">
<label for="pwd-current">Senha atual</label> <label for="pwd-current">Senha atual</label>
<div class="password-field"> <div class="password-field">
<input type="password" id="pwd-current" autocomplete="current-password" /> <input type="password" id="pwd-current" name="senha_atual" autocomplete="current-password" />
<button type="button" class="password-field__toggle" id="pwd-current-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button> <button type="button" class="password-field__toggle" id="pwd-current-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
</div> </div>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="pwd-new">Nova senha</label> <label for="pwd-new">Nova senha</label>
<div class="password-field"> <div class="password-field">
<input type="password" id="pwd-new" autocomplete="new-password" /> <input type="password" id="pwd-new" name="nova_senha" autocomplete="new-password" />
<button type="button" class="password-field__toggle" id="pwd-new-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button> <button type="button" class="password-field__toggle" id="pwd-new-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
</div> </div>
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="pwd-confirm">Confirmar nova senha</label> <label for="pwd-confirm">Confirmar nova senha</label>
<div class="password-field"> <div class="password-field">
<input type="password" id="pwd-confirm" autocomplete="new-password" /> <input type="password" id="pwd-confirm" name="confirmar_senha" autocomplete="new-password" />
<button type="button" class="password-field__toggle" id="pwd-confirm-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button> <button type="button" class="password-field__toggle" id="pwd-confirm-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
</div> </div>
</div> </div>

View File

@ -349,11 +349,11 @@
<div class="pa-tab-panel" data-ram-tab-panel="ramais"> <div class="pa-tab-panel" data-ram-tab-panel="ramais">
<div class="ram-filters"> <div class="ram-filters">
<div class="ram-filter"> <div class="ram-filter">
<input type="text" id="ram-search-nome" placeholder="Pesquisar pelo nome do colaborador..." autocomplete="off" /> <input type="text" id="ram-search-nome" placeholder="Pesquisar pelo nome do colaborador…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
<div class="ram-filter"> <div class="ram-filter">
<input type="text" id="ram-search-departamento" placeholder="Pesquisar pelo departamento..." autocomplete="off" /> <input type="text" id="ram-search-departamento" placeholder="Pesquisar pelo departamento…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -396,11 +396,11 @@
<div class="pa-tab-panel" data-ram-tab-panel="telefones-externos" hidden> <div class="pa-tab-panel" data-ram-tab-panel="telefones-externos" hidden>
<div class="ram-filters"> <div class="ram-filters">
<div class="ram-filter"> <div class="ram-filter">
<input type="text" id="ram-tel-search-nome" placeholder="Pesquisar pelo Nome..." autocomplete="off" /> <input type="text" id="ram-tel-search-nome" placeholder="Pesquisar pelo Nome…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
<div class="ram-filter"> <div class="ram-filter">
<input type="text" id="ram-tel-search-obs" placeholder="Pesquisar pelas Observações..." autocomplete="off" /> <input type="text" id="ram-tel-search-obs" placeholder="Pesquisar pelas Observações…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
</div> </div>
@ -467,16 +467,16 @@
<p class="modal-card__subtitle" id="ram-form-subtitle" hidden>Colaboradores com cadastro de usuário aparecem automaticamente no diretório — aqui só se ajusta o ramal.</p> <p class="modal-card__subtitle" id="ram-form-subtitle" hidden>Colaboradores com cadastro de usuário aparecem automaticamente no diretório — aqui só se ajusta o ramal.</p>
<div class="modal-field"> <div class="modal-field">
<label for="ram-form-nome">Nome</label> <label for="ram-form-nome">Nome</label>
<input type="text" id="ram-form-nome" placeholder="Nome:" autocomplete="off" /> <input type="text" id="ram-form-nome" placeholder="Ex.: Recepção" autocomplete="off" />
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="ram-form-departamento">Departamento</label> <label for="ram-form-departamento">Departamento</label>
<input type="text" id="ram-form-departamento" placeholder="Departamento:" autocomplete="off" /> <input type="text" id="ram-form-departamento" placeholder="Ex.: Administrativo" autocomplete="off" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="ram-form-numero">Ramal</label> <label for="ram-form-numero">Ramal</label>
<input type="text" id="ram-form-numero" placeholder="Ramal:" autocomplete="off" /> <input type="text" id="ram-form-numero" placeholder="Ex.: 201" autocomplete="off" inputmode="numeric" />
</div> </div>
</div> </div>
<p class="modal-error" id="ram-form-error"></p> <p class="modal-error" id="ram-form-error"></p>
@ -599,16 +599,16 @@
<h2 class="modal-card__title" id="tel-form-title">Adicionar Telefone Externo</h2> <h2 class="modal-card__title" id="tel-form-title">Adicionar Telefone Externo</h2>
<div class="modal-field"> <div class="modal-field">
<label for="tel-form-nome">Nome</label> <label for="tel-form-nome">Nome</label>
<input type="text" id="tel-form-nome" placeholder="Nome:" autocomplete="off" /> <input type="text" id="tel-form-nome" placeholder="Ex.: Suporte do Questor" autocomplete="off" />
</div> </div>
<div class="modal-field-row"> <div class="modal-field-row">
<div class="modal-field"> <div class="modal-field">
<label for="tel-form-ramal">Ramal</label> <label for="tel-form-ramal">Ramal</label>
<input type="text" id="tel-form-ramal" placeholder="Ramal:" autocomplete="off" /> <input type="text" id="tel-form-ramal" placeholder="Ex.: 201" autocomplete="off" inputmode="numeric" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="tel-form-telefone">Telefone</label> <label for="tel-form-telefone">Telefone</label>
<input type="text" id="tel-form-telefone" placeholder="Telefone:" autocomplete="off" /> <input type="tel" id="tel-form-telefone" placeholder="Ex.: (41) 3333-4444" autocomplete="off" />
</div> </div>
</div> </div>
<div class="modal-field"> <div class="modal-field">
@ -628,7 +628,7 @@
<h2 class="modal-card__title" id="fun-form-title">Adicionar Função de Telefonia</h2> <h2 class="modal-card__title" id="fun-form-title">Adicionar Função de Telefonia</h2>
<div class="modal-field"> <div class="modal-field">
<label for="fun-form-comando">Comando</label> <label for="fun-form-comando">Comando</label>
<input type="text" id="fun-form-comando" placeholder="Ex.: *01 + Código de Agente" autocomplete="off" /> <input type="text" id="fun-form-comando" placeholder="Ex.: *01 + Código de Agente" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="modal-field"> <div class="modal-field">
<label for="fun-form-funcao">Função</label> <label for="fun-form-funcao">Função</label>

View File

@ -333,7 +333,7 @@
</div> </div>
<div class="pa-toolbar"> <div class="pa-toolbar">
<div class="pa-search"> <div class="pa-search">
<input type="text" id="ua-search-input" placeholder="Pesquisar usuário..." autocomplete="off" /> <input type="text" id="ua-search-input" placeholder="Pesquisar usuário…" autocomplete="off" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg>
</div> </div>
<button type="button" class="btn-solid" id="ua-new-btn"> <button type="button" class="btn-solid" id="ua-new-btn">
@ -350,9 +350,9 @@
<th data-sort="username" style="width:130px;">Login <span class="ua-sort-icon">↕</span></th> <th data-sort="username" style="width:130px;">Login <span class="ua-sort-icon">↕</span></th>
<th data-sort="nome">Nome <span class="ua-sort-icon">↕</span></th> <th data-sort="nome">Nome <span class="ua-sort-icon">↕</span></th>
<th data-sort="codigo_folha" style="width:110px;">Cód. Folha <span class="ua-sort-icon">↕</span></th> <th data-sort="codigo_folha" style="width:110px;">Cód. Folha <span class="ua-sort-icon">↕</span></th>
<th data-sort="codigo_questor" style="width:120px;">Cód. Questor <span class="ua-sort-icon">↕</span></th> <th data-sort="codigo_questor" translate="no" style="width:120px;">Cód. Questor <span class="ua-sort-icon">↕</span></th>
<th data-sort="codigo_tareffa" style="width:120px;">Cód. Tareffa <span class="ua-sort-icon">↕</span></th> <th data-sort="codigo_tareffa" translate="no" style="width:120px;">Cód. Tareffa <span class="ua-sort-icon">↕</span></th>
<th data-sort="codigo_contabit" style="width:120px;">Cód. Contabit <span class="ua-sort-icon">↕</span></th> <th data-sort="codigo_contabit" translate="no" style="width:120px;">Cód. Contabit <span class="ua-sort-icon">↕</span></th>
<th data-sort="ramal" style="width:90px;">Ramal <span class="ua-sort-icon">↕</span></th> <th data-sort="ramal" style="width:90px;">Ramal <span class="ua-sort-icon">↕</span></th>
<th style="width:180px;">Perfil de Acesso</th> <th style="width:180px;">Perfil de Acesso</th>
<th data-sort="status" style="width:100px;">Status <span class="ua-sort-icon">↕</span></th> <th data-sort="status" style="width:100px;">Status <span class="ua-sort-icon">↕</span></th>
@ -385,11 +385,11 @@
<div class="pa-field-row pa-field-row--auto"> <div class="pa-field-row pa-field-row--auto">
<div class="pa-field"> <div class="pa-field">
<label for="ua-login">Login <span class="required">*</span></label> <label for="ua-login">Login <span class="required">*</span></label>
<input type="text" id="ua-login" placeholder="Ex.: maria.silva" autocomplete="off" /> <input type="text" id="ua-login" placeholder="Ex.: maria.silva" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="ua-nome">Nome <span class="required">*</span></label> <label for="ua-nome">Nome <span class="required">*</span></label>
<input type="text" id="ua-nome" placeholder="Ex.: Maria Silva" /> <input type="text" id="ua-nome" placeholder="Ex.: Maria Silva" autocomplete="off" />
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="ua-senha">Senha <span class="required" id="ua-senha-required">*</span></label> <label for="ua-senha">Senha <span class="required" id="ua-senha-required">*</span></label>
@ -408,12 +408,12 @@
<div class="pa-field-row pa-field-row--auto"> <div class="pa-field-row pa-field-row--auto">
<div class="pa-field"> <div class="pa-field">
<label for="ua-email">E-mail</label> <label for="ua-email">E-mail</label>
<input type="email" id="ua-email" placeholder="Ex.: maria.silva@depaulacontadores.com.br" autocomplete="off" /> <input type="email" id="ua-email" placeholder="Ex.: maria.silva@depaulacontadores.com.br" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="ua-codigo-folha">Código da Folha</label> <label for="ua-codigo-folha">Código da Folha</label>
<div class="ua-field-link"> <div class="ua-field-link">
<input type="text" id="ua-codigo-folha" placeholder="Ex.: 567/327" autocomplete="off" /> <input type="text" id="ua-codigo-folha" placeholder="Ex.: 567/327" autocomplete="off" spellcheck="false" />
<button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada"> <button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg>
Vincular Vincular
@ -423,7 +423,7 @@
<div class="pa-field"> <div class="pa-field">
<label for="ua-codigo-questor">Código do Questor</label> <label for="ua-codigo-questor">Código do Questor</label>
<div class="ua-field-link"> <div class="ua-field-link">
<input type="text" id="ua-codigo-questor" placeholder="Não cadastrado" autocomplete="off" /> <input type="text" id="ua-codigo-questor" placeholder="Não cadastrado" autocomplete="off" spellcheck="false" />
<button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada"> <button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg>
Vincular Vincular
@ -433,7 +433,7 @@
<div class="pa-field"> <div class="pa-field">
<label for="ua-codigo-tareffa">Código do Tareffa</label> <label for="ua-codigo-tareffa">Código do Tareffa</label>
<div class="ua-field-link"> <div class="ua-field-link">
<input type="text" id="ua-codigo-tareffa" placeholder="Não cadastrado" autocomplete="off" /> <input type="text" id="ua-codigo-tareffa" placeholder="Não cadastrado" autocomplete="off" spellcheck="false" />
<button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada"> <button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg>
Vincular Vincular
@ -442,11 +442,11 @@
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="ua-codigo-contabit">Código do Contabit</label> <label for="ua-codigo-contabit">Código do Contabit</label>
<input type="text" id="ua-codigo-contabit" placeholder="Não cadastrado" autocomplete="off" /> <input type="text" id="ua-codigo-contabit" placeholder="Não cadastrado" autocomplete="off" spellcheck="false" />
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="ua-ramal">Ramal</label> <label for="ua-ramal">Ramal</label>
<input type="text" id="ua-ramal" placeholder="Não cadastrado" autocomplete="off" /> <input type="text" id="ua-ramal" placeholder="Não cadastrado" autocomplete="off" inputmode="numeric" spellcheck="false" />
</div> </div>
<div class="pa-field"> <div class="pa-field">
<label for="ua-data-aniversario">Data de Aniversário</label> <label for="ua-data-aniversario">Data de Aniversário</label>
@ -466,7 +466,7 @@
<label>Departamento</label> <label>Departamento</label>
<div class="checklist-box" id="ua-departamento-checklist"></div> <div class="checklist-box" id="ua-departamento-checklist"></div>
<div class="ua-inline-add"> <div class="ua-inline-add">
<input type="text" id="ua-departamento-new" placeholder="Novo departamento..." autocomplete="off" /> <input type="text" id="ua-departamento-new" placeholder="Novo departamento…" autocomplete="off" />
<button type="button" class="btn-outline" id="ua-departamento-add-btn">Adicionar</button> <button type="button" class="btn-outline" id="ua-departamento-add-btn">Adicionar</button>
</div> </div>
<p class="pa-field-error" id="ua-departamento-error"></p> <p class="pa-field-error" id="ua-departamento-error"></p>
@ -497,8 +497,8 @@
</tr> </tr>
<tr class="dual-select__filters"> <tr class="dual-select__filters">
<th></th> <th></th>
<th><input type="text" id="ua-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th> <th><input type="text" id="ua-liderados-available-search-nome" placeholder="Buscar por nome…" autocomplete="off" /></th>
<th><input type="text" id="ua-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th> <th><input type="text" id="ua-liderados-available-search-departamento" placeholder="Buscar por departamento…" autocomplete="off" /></th>
</tr> </tr>
</thead> </thead>
<tbody id="ua-liderados-available-body"></tbody> <tbody id="ua-liderados-available-body"></tbody>
@ -521,8 +521,8 @@
</tr> </tr>
<tr class="dual-select__filters"> <tr class="dual-select__filters">
<th></th> <th></th>
<th><input type="text" id="ua-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th> <th><input type="text" id="ua-liderados-linked-search-nome" placeholder="Buscar por nome…" autocomplete="off" /></th>
<th><input type="text" id="ua-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th> <th><input type="text" id="ua-liderados-linked-search-departamento" placeholder="Buscar por departamento…" autocomplete="off" /></th>
</tr> </tr>
</thead> </thead>
<tbody id="ua-liderados-linked-body"></tbody> <tbody id="ua-liderados-linked-body"></tbody>