Revisão do js.

This commit is contained in:
Gabriel 2026-09-25 15:45:38 -03:00
parent 0287de814e
commit ce611ad9ca
30 changed files with 1171 additions and 190 deletions

4
.gitignore vendored
View File

@ -59,3 +59,7 @@ desktop.ini
# Configuração local do Claude Code
.claude/settings.local.json
# Regras gerais de UI (uso local) e documentos de revisão gerados a partir delas
AGENT.MD
docs/revisao-interface-*.docx

View File

@ -162,6 +162,8 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio `
`pidApiRequest` (em `api.js`) detecta `body instanceof FormData` e, nesse caso, **não** faz `JSON.stringify` nem define `Content-Type` manualmente — deixa o browser montar o `multipart/form-data` com o boundary certo. Usado pelo upload de `icone` em Links & Ferramentas e pelos dois arquivos anexados em "Nova Importação" de Plano de Saúde; todo o resto da API é JSON puro. A única resposta binária da API (`/importacoes-plano-saude/{id}/gerar/`, que devolve CSV/ZIP) não passa por `pidApiRequest` — usa um `fetch` manual dedicado (ver seção "Importação de Plano de Saúde").
**Escape de HTML obrigatório (`pidEscapeHtml`, `api.js`)**: todo dado vindo da API, do usuário ou de arquivo anexado que entra em HTML montado por template string (`innerHTML`, `insertAdjacentHTML`) passa por `pidEscapeHtml(texto)`, que trata `& < > " '` e serve para conteúdo **e** atributo. Sem isso, um texto com HTML (título de compromisso, nome de link, observação, dado do arquivo da operadora) vira código executado no navegador de quem abre a tela, inclusive de outros usuários (XSS armazenado). Corrigido em todo o frontend na revisão de interface de 2026-09-25 (ver `plano.md`). Regras: escapar o **dado**, num único ponto (sem escape duplo), nunca o HTML que o próprio código monta (ícones, fragmentos); dado atribuído por `.textContent`/`.value` já é seguro e não se escapa; **texto rico sanitizado no servidor com `nh3`** (Ajuda, observações de Acessos Gerais, Resumo do Fechamento) é HTML intencional e **não** se escapa. As funções locais de escape que ainda existem (`pidDcEscapeHtml`, `pidNcfEscapeHtml`, `pidIndEscapeHtml`, `pidConcEscape`, `escapeHtml`/`escapeAttr` do Plano de Saúde) delegam a ela; a versão antiga por `textContent`/`innerHTML` não escapava aspas e não deve voltar a ser usada.
`pidApplyAccessVisibility` não recalcula mais união de permissões no cliente — usa `me.permissoes_efetivas`, já unida no backend (`permissoes_efetivas()` em `views.py`).
## Páginas

View File

@ -478,6 +478,61 @@ a doc dela junto. Na prática quase nunca traz: o código de todas elas vive em
Editar os models ou as views do Relatório Contábil não dispara o `CLAUDE.md`
dele.
### 150. Escape de HTML em todo o frontend (XSS) (2026-09-25)
Primeira correção da revisão de interface feita com a skill `web-design-guidelines`
(relatório em `docs/revisao-interface-2026-09-25.docx`, item 1.1; versão
explicada em `docs/revisao-interface-2026-09-25-resumo.docx`). O usuário pediu
para aplicar **só este item** por enquanto.
**Problema**: em várias telas, dado vindo da API, do usuário ou de arquivo
anexado entrava em `innerHTML` sem escape, ou com uma função de escape que não
tratava aspas e era usada dentro de atributo. Um texto com HTML virava código
executado no navegador de quem abria a tela. Nos casos de dado compartilhado
(compromisso com visibilidade "todos" no sino e no widget, links, ramais), no
navegador de **outros usuários** (XSS armazenado).
**Correção**:
- Função compartilhada `pidEscapeHtml(texto)` em `static/js/api.js` (carregado
em todos os 15 shells, antes dos outros scripts), tratando `& < > " '`, para
conteúdo e atributo.
- As funções locais passaram a delegar a ela: `pidDcEscapeHtml`/`dcEscapeAttr`
(Relatório Contábil), `pidNcfEscapeHtml` (Não Conformidades),
`pidIndEscapeHtml`/`pidIndEscapeAttr` (Indicador), `pidConcEscape`/
`pidConcEscapeAttr` (Conciliação), `escapeHtml`/`escapeAttr` (Plano de Saúde).
As três primeiras e `pidConcEscape` eram baseadas em `textContent` e deixavam
`"` passar (observação do contador em `aria-label`, tipo de ocorrência em
`value`, nome de quem validou em `title`).
- Escape aplicado nas interpolações sem nenhum escape: `notifications.js`
(título/dono do compromisso, escapados uma vez na criação do objeto),
`widgets.js`, `dual-select.js`, `favorites.js`, `links-ferramentas.js`,
`acessos-gerais.js`, `calendar-individual.js`, `ramais-lookup.js`,
`ramais.js`, `profiles.js`, `users-admin.js`, `importacao-plano-saude.js`
(auditoria com nome/CPF/detalhe do arquivo da operadora, histórico, abas,
alterações), `nao-conformidades.js` (códigos), `indicador-desempenho.js` e
`dashboard-contabil.js` (rótulos com valor de reserva vindo da API, chaves de
indicador personalizado). `custo-contratacao.js`, `ajuda-aplicacao.js`,
`account.js`, `seasonal-theme.js`, `search.js`, `access.js`, `sidebar.js`,
`auth.js` e `events.js` foram revisados e não tinham ponto inseguro.
- **Não escapado de propósito**: texto rico sanitizado no servidor com `nh3`
(Ajuda, observações de Acessos Gerais, Resumo do Fechamento), HTML montado pelo
próprio código (ícones, fragmentos), dado atribuído por `textContent`/`value`,
mensagens de `pidConfirm`/`pidAlert` (o modal usa `textContent`) e ids
numéricos. `href` com `javascript:` não é risco porque `LinkFerramenta.url` e
`AcessoGeral.url` são `URLField` (só http/https/ftp).
- Única mudança visível: onde um campo vinha vazio (`null`), a célula mostrava
"undefined" em alguns pontos do Plano de Saúde e agora fica vazia.
**Validação**: só frontend, sem banco, API ou migration, e não exige reiniciar o
`runserver`. Sintaxe dos 17 arquivos alterados conferida com um analisador de
JavaScript (esprima, instalado só na pasta de rascunho da sessão). **Não
testado no navegador** (sem renderização neste ambiente). Roteiro sugerido:
cadastrar textos como `Teste <b>negrito</b> "aspas" & cia` em compromisso, link,
ramal, perfil, acesso e observação, e conferir que aparecem **literalmente** em
todas as telas onde são exibidos, sem `&amp;` sobrando. A regra ficou registrada
no `CLAUDE.md` ("Frontend consumindo a API").
## Limitações conhecidas / decisões assumidas
- Calendário De Paula (empresa) não tem mais versão interna — depende

View File

@ -10,6 +10,7 @@ from .models import (
ConciliacaoFornecedorConta,
ConciliacaoFornecedorLancamento,
ConciliacaoFornecedorVinculo,
ConciliacaoFornecedorVinculoHistorico,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
@ -334,6 +335,12 @@ class ConciliacaoFornecedorVinculoAdmin(admin.ModelAdmin):
list_filter = ("tipo",)
@admin.register(ConciliacaoFornecedorVinculoHistorico)
class ConciliacaoFornecedorVinculoHistoricoAdmin(admin.ModelAdmin):
list_display = ("conta", "acao", "tipo_rotulo", "usuario", "criado_em")
list_filter = ("acao",)
@admin.register(ConciliacaoFornecedorLancamento)
class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin):
list_display = ("conta", "data", "natureza", "valor", "historico", "vinculo")

View File

@ -102,3 +102,57 @@ A pedido do usuário, "Natureza" + "Valor" viraram colunas "Débito" + "Crédito
### Rodada 19 — Toggle "Ocultar conciliados" no razão da conta expandida (2026-09-25)
Pedido do usuário: um botão para esconder o que já está conciliado no razão desde a primeira pendência, **desmarcado por padrão**. Checkbox "Ocultar conciliados" (estilo `.conc-toggle`, o mesmo de "Mostrar validadas") ao lado do título "Razão desde ... · N não conciliado(s)", com a contagem de linhas vinculadas no período; só aparece quando há alguma. Esconde as `tr.conc-linha--vinculada` pela classe `is-ocultando-conciliados` na tabela; o estado fica por conta (`ocultarConciliados` em `estadoDe`) e é mantido depois de vincular/desfazer. Só frontend (JS/CSS), sem mudança de API.
### Rodada 20 — Sugestão de parcela com pequena diferença (2026-09-25)
Pergunta do usuário (ROZINI): por que o pagamento de 14/07 (R$ 2.163,55) era sugerido como parcela do título de 28/05 (R$ 8.654,22) e o de 26/06 (R$ 2.163,57) só como "pode ser pagamento do título"? A regra de parcela aceitava só o arredondamento da divisão (até 1 centavo por parcela no total: 4 × 2.163,57 = 8.654,28, R$ 0,06 acima, limite R$ 0,04). O usuário escolheu, entre aumentar a tolerância e mostrar a diferença, **mostrar a diferença**: `_eh_parcela()` virou `_parcela()`, que devolve a parcela de referência e a diferença; parcela exata continua tendo prioridade, e sem ela vale o pagamento a até 2% (`TOLERANCIA_PERCENTUAL_PADRAO` de `motor.py`, só o percentual) da parcela arredondada mais próxima. Texto novo: "Possível parcela do título de 28/05/2026 (R$ 8.654,22), com R$ 0,01 a mais que a parcela de R$ 2.163,56 (juros/desconto?). Vincule para confirmar." Só sugestão, nenhum valor ou vínculo muda. Testado com os três lançamentos da ROZINI chamando `_analisa_pendentes` direto.
### Rodada 21 — Linhas do razão com altura fixa (2026-09-25)
Pedido do usuário: em tela mais estreita, o histórico quebrava em 2 linhas (mais a contrapartida numa terceira) e a sugestão longa também, deixando as linhas do razão altas. Histórico, contrapartida e sugestão passaram a ficar em uma linha cada, com reticências e o texto completo ao passar o mouse (`title`). Colunas Histórico (30%) e Situação (34%) com `max-width: 0`, mesmo truque da coluna de fornecedor, para cederem espaço sem alargar a tabela. Helper novo `pidConcHistoricoHtml()` (usado no razão e nos blocos de vínculo) e `pidConcEscapeAttr()` (o `pidConcEscape` não escapa aspas). Contrapartida ao lado do histórico foi descartada (ajuda a distinguir pagamento de relance). Sem renderização neste ambiente: conferir por print.
Na mesma rodada, depois do print do usuário: o `title` nativo do navegador não estava no padrão visual do Portal. Trocado por um tooltip próprio (`data-conc-tooltip` + `.conc-tooltip`, mesmo visual de `.dc-hover-tooltip__texto` do Relatório Contábil): um único elemento `position: fixed` no body, delegado no document (`mouseover`/`mouseout`), posicionado acima do texto (abaixo se não couber), escondido ao rolar, e só exibido quando o texto está de fato cortado (`scrollWidth > clientWidth`). Depois de mais um print, o balão ficou apagado sobre o fundo escuro: ganhou borda na cor do tema (`rgba(var(--accent-rgb), 0.55)`) e `--shadow-md` no lugar da borda neutra e `--shadow-sm`.
### Rodada 22 — Destaque das linhas da sugestão no hover (2026-09-25)
Pedido do usuário: ao passar o mouse na sugestão, destacar as linhas envolvidas, como já acontece com um vínculo. `SituacaoItem.relacionados` (ids do outro lado da distribuição sugerida, já calculado em `alertas.py`) passou a ir no payload de `situacao` (e no do saldo de vínculo parcial); id negativo é o saldo de um vínculo parcial (`-vinculo.id`) e destaca as linhas daquele vínculo. Na tela, `data-conc-relacionados` na `.conc-sugestao` e a classe `is-sugestao-grupo` na própria linha + relacionadas. Cor dourada (`--gold-rgb`), não o verde do vínculo (sugestão não é vínculo) nem o roxo, que já é o de linha selecionada.
### Rodada 23 — Histórico de vinculações e desvinculações (2026-09-25)
Pedido do usuário: saber quando houve vinculação ou desvinculação manual e com quais seq., por um botão "i" no canto. Model novo `ConciliacaoFornecedorVinculoHistorico` (migration `0084_conciliacao_historico_vinculos`, aplicada) gravado em `vincular/`, `adicionar/` e no `DELETE` do vínculo (inclusive desfazer vínculo automático), com usuário, data/hora, tipo e cópia dos lançamentos (o vínculo desfeito é apagado). Endpoint `GET /conciliacoes-fornecedores-contas/{id}/historico-vinculos/`. Na tela, botão "i" à direita de "Vincular selecionados" (tooltip "Histórico de vinculações", o `data-conc-tooltip` ganhou `data-conc-tooltip-sempre` para botão só com ícone) e modal com a lista. Decisões minhas, não confirmadas: histórico **por conta** (não por lançamento); vínculos do motor na importação não entram. Testado pela API com a ROZINI (vincular → acrescentar → desfazer → histórico) dentro de transação desfeita no fim. Sem renderização do modal neste ambiente.
### Rodada 24 — Sugestão prioriza título de mesmo valor (2026-09-25)
Pergunta do usuário (ROZINI): depois de desfazer o vínculo automático exato do pagamento de 09/06 (R$ 6.450,57), a sugestão apontava o título de 28/05 (R$ 8.654,22) em vez do título de 09/06 de mesmo valor. Causa: a distribuição das sugestões só tentava parcela e, sem ela, o título mais antigo com saldo; valor exato nem era considerado, e o erro se propagava para as sugestões dos dois títulos. Correção: passada própria de **pares de mesmo valor** antes da ordem por data (desempate do `_pareia_exato` do motor), com a sugestão "Mesmo valor do título de ...". Na mesma rodada, a pedido do usuário, o último recurso (título mais antigo com saldo) ganhou texto que deixa claro ser sugestão fraca: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ...". Testado chamando `_analisa_pendentes` com os lançamentos do print.
### Rodada 25 — Histórico e contrapartida no histórico de vinculações (2026-09-25)
Pedido do usuário, entre quatro melhorias sugeridas para o modal (escolheu só esta): cada lançamento do histórico de vinculações mostra o histórico e a contrapartida, em uma linha cada com reticências e o tooltip do Portal. Lidos na hora pelo id no `historico-vinculos/` (o lançamento não muda depois da importação), então valem também para os registros já gravados. Grid do item ganhou a coluna; a etiqueta Débito/Crédito deixou de esticar (envolvida num `span`). `.conc-tooltip` subiu para `z-index: 200`, acima do `.modal-overlay`. **Sugeridas e não aplicadas** (não reapresentar como novidade): resultado do vínculo (totais/"resta R$ X"), origem do vínculo desfeito (quem criou e quando) e trocar "Manual · manual" por só "Manual".
### Rodada 26 — Duplo clique na sugestão seleciona as linhas envolvidas (2026-09-25)
Pedido do usuário: duplo clique na sugestão seleciona a própria linha e as relacionadas (as mesmas do destaque dourado da rodada 22); outro duplo clique remove a seleção. Regra: se todas do grupo já estão marcadas, desmarca; senão, marca todas. Só entram linhas não vinculadas (o saldo de vínculo parcial, id negativo, fica só no destaque). Usa o `change` de cada checkbox, então soma da barra e "Vincular selecionados" se atualizam sozinhos. Efeito colateral deliberado: clique simples **dentro da sugestão** deixa de marcar/desmarcar a linha (senão o duplo clique marcaria e desmarcaria antes); fora dela, a linha continua clicável.
### Rodada 27 — Quantidade de parcelas na sugestão (2026-09-25)
Pedido do usuário: a sugestão de possível parcela passa a dizer em quantas vezes. `_parcela()` devolve também `k` (`(k, referência, diferença)`). Textos: "Possível parcela do título de 01/07/2026 (R$ 1.656,80), em 3x de R$ 552,27. Vincule para confirmar." e, com diferença, "... em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?). ...". No exemplo do usuário (AUDIO 5, R$ 1.656,80 / R$ 552,27) o cálculo é 3x, não 4x como ele supôs (3 × 552,27 = 1.656,81). Não indica **qual** parcela é (1ª, 2ª...), porque o razão não traz essa informação.
### Rodada 28 — Soma e saldo residual quando a sugestão cita vários títulos (2026-09-25)
Pedido do usuário (pagamento URBANO de R$ 616,82 sugerido contra 10 títulos de frete): quando a sugestão de último recurso cita mais de um título, mostrar a soma e o saldo residual. Texto acrescentado: "Soma: R$ 717,41; saldo residual de R$ 100,59 em aberto." (ou "o pagamento excede em R$ X" quando nem todos os títulos cobrem o pagamento). A soma é do saldo **disponível** de cada título no momento (`disponivel_usado`), que pode ser menor que o valor cheio exibido no rótulo quando um pagamento anterior da distribuição já abateu parte dele. Com um título só, o texto não muda.
### Rodada 29 — Detalhe do vínculo pelo "i" da linha (2026-09-25)
Pedido do usuário: um "i" na linha vinculada para ver o que o vínculo automático considerou (as outras linhas do vínculo podem estar longe no razão; o destaque no hover não basta). Botão pequeno ao lado de "Desfazer" abre o modal "Vínculo #N": tipo, automático/manual (com o nome de quem criou, no manual), o critério usado em texto (`PID_CONC_CRITERIO_VINCULO`, um por tipo do motor), os lançamentos (mesma linha do histórico de vinculações, com histórico e contrapartida) e os totais de créditos, débitos e diferença. Só frontend: tudo já vinha no payload da conta. O modal do histórico virou genérico (`pidConcAbreModalInfo(titulo, subtitulo)`), usado pelos dois.
### Rodada 30 — Diferença de juros/desconto vira sugestão, não vínculo automático (2026-09-25)
Pedido do usuário (BRASPRESS, título R$ 133,81 × pagamento R$ 127,61 vinculados automaticamente "com diferença"): no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros. A etapa 5 do motor (tolerância) saiu de `concilia()`, que deixou de receber `tolerancia` (e `_conciliacao_grava_contas` também). Em `alertas.py`, passada nova de **valor próximo** depois da de mesmo valor: 1:1 dentro da tolerância da conciliação, menor diferença primeiro; sugestão nos dois lados ("Possível pagamento do título de 11/05/2026 (R$ 133,81), com R$ 6,20 a menos (possível desconto). Vincule para confirmar." / "Possível pagamento em 26/06/2026 (R$ 127,61), ..."). `analisa_conta` recebe a `Tolerancia` da conciliação. Vale para **novas importações**; os 21 vínculos `tolerancia` já gravados (uma conciliação) **não foram alterados**: o usuário decidiu deixá-los e refazer a conciliação com uma nova importação; o texto do critério no modal "Vínculo #N" avisa que o critério só valeu até esta data. Risco conhecido: com o piso fixo de R$ 10, em títulos pequenos (fretes de R$ 40) a sugestão de valor próximo pode aparecer por acaso; se incomodar, usar só o percentual, como na parcela.
### Rodada 31 — Conta validada sai da lista na hora (2026-09-25)
Bug relatado pelo usuário: ao marcar "Validado", a conta não ia para os validados na hora, às vezes só depois de recarregar a página. Causa: `aplicarRespostaConta()` só redesenhava a linha no lugar (`substituirConta()`), sem reaplicar o filtro; a conta só sumia no próximo render da lista inteira (trocar chip, buscar, recarregar). A regra do filtro virou `contaPassaFiltro()` (usada por `contasFiltradas()` e depois de cada ajuste); conta que não cabe mais sai com fade de 160ms (`removerConta()`, classe `is-saindo`), com a mensagem de lista vazia quando for a última. Vale para validar/desvalidar e para vincular/desfazer que mude a situação com um chip ativo.
### Rodada 32 — Quitação dos títulos mais antigos como vínculo automático (2026-09-25)
Pergunta do usuário (BRASPRESS): o pagamento de R$ 234,40 era exatamente a soma dos 4 títulos mais antigos em aberto (a própria sugestão mostrava "saldo residual de R$ 0,00"), por que não foi vinculado? Causa: a combinação só considera os 12 candidatos mais próximos em data (`MAX_CANDIDATOS_COMBINACAO`), e esses 4 estavam nas posições 16 a 19 entre 80. Proposta aceita pelo usuário: etapa nova no motor, `_quita_mais_antigos()` (`TIPO_SEQUENCIA = "sequencia"`, "Quitação dos títulos mais antigos"; migration `0085_conciliacao_vinculo_sequencia`, aplicada, só as choices), depois da combinação e antes da compensação do segmento fechado: pagamento contra a sequência dos títulos mais antigos em aberto, só com soma exata e 2 títulos ou mais, sem limite de itens. Rodando o motor novo sobre a conciliação 20 (só leitura): 8 vínculos desse tipo, todos com diferença zero (BRASPRESS 234,40 = 4 e 616,82 = 7 títulos; TEX COURIER 468,74 = 11; SUD LOG, JOICEMAR, LEOMAR, SEARA, F N BERLANDA). Vale para novas importações. Critério explicado no modal "Vínculo #N".

View File

@ -32,8 +32,9 @@ Função pura `concilia(itens, tolerancia) -> list[VinculoSugerido]`, uma conta
1. **Segmentação por saldo zerado**: cada ponto em que o saldo corrente (na ordem do arquivo) volta a zero fecha um segmento, e vínculo nunca atravessa segmento. Isso evita casar o frete de janeiro com um pagamento de mesmo valor de julho.
2. **1:1 por valor exato**, menor distância de datas.
3. **Combinação exata** (vários débitos = um crédito, ou vários créditos, como NF + juros, = um débito): até 6 itens, janela de 120 dias, **só os 12 candidatos mais próximos em data**, teto de 20 mil nós por busca. O limite de candidatos existe porque, numa conta com centenas de lançamentos pequenos (EBAZAR, 456 no arquivo real), uma soma exata entre itens sem relação é quase certa se todos puderem participar.
4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`.
5. **Tolerância**, só no último segmento (o que está em aberto): 1:1 ou 2 itens com diferença até o limite. Com 3 ou mais itens, combinações "a menos de R$ 10" aparecem por acaso.
4. **Quitação dos títulos mais antigos** (`_quita_mais_antigos`, `tipo="sequencia"`, desde 2026-09-25): cada pagamento pendente, em ordem de data, contra a sequência dos títulos mais antigos em aberto (data até a do pagamento); vira vínculo só se a soma fechar **exatamente**, com 2 títulos ou mais. Sem limite de itens nem de candidatos, porque há uma única sequência possível (não é subset-sum). Motivo: fretes acumulados que a combinação não achava por estarem fora dos 12 mais próximos (BRASPRESS: R$ 234,40 = 4 títulos; R$ 616,82 = 7; TEX COURIER: R$ 468,74 = 11). Roda em todo segmento, inclusive nos fechados, antes da compensação.
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`.
5. ~~Tolerância~~ **removida em 2026-09-25** (pedido do usuário: "no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros"). Era 1:1 ou 2 itens com diferença até o limite, só no último segmento. Hoje esse par fica pendente e vira **sugestão** em `alertas.py` (ver "valor próximo" abaixo). `TIPO_TOLERANCIA` continua existindo pelos vínculos gravados antes (21 numa conciliação, na data da mudança); `concilia()` não recebe mais `tolerancia`.
Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensações, 21 com diferença.
@ -43,7 +44,7 @@ Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensaç
**Fato x sugestão: só vínculo muda valor** (pedido do usuário). Um título sem vínculo está em aberto pelo **valor cheio**; um pagamento sem vínculo é "Pagamento não vinculado". O razão não traz parcelamento, vencimento nem NF no pagamento, então qualquer dedução sobre qual título um pagamento paga é **sugestão**: vai em `SituacaoItem.sugestao` (texto à parte), nunca na `descricao` (só fato) nem em `valor_em_aberto`. Na tela, a sugestão aparece numa linha própria abaixo do fato, com a etiqueta "Sugestão", em itálico e cor neutra; no XLSX, depois de "Sugestão:". O contador confirma criando o vínculo manual (inclusive o de pagamento parcial, que aí sim reduz o valor em aberto).
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: primeiro o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela), senão o título mais antigo com saldo. Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável.
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `TOLERANCIA_PERCENTUAL_PADRAO`% (2%) da parcela arredondada mais próxima, só o percentual, sem o piso fixo de R$ 10, e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?)"; ver `_parcela()` em `alertas.py`), senão o título mais antigo com saldo (sugestão fraca, e o texto diz isso: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."; com mais de um título, acrescenta "Soma: R$ X; saldo residual de R$ Y em aberto." ou "...; o pagamento excede em R$ Y.", sendo a soma do **saldo ainda disponível** de cada título na distribuição, não do valor cheio). Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável.
| Categoria | Significado (fato) | Alerta |
|---|---|---|
@ -62,7 +63,7 @@ Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `pendente`
## Models (`portal_api/models.py`)
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente).
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro.
## API
@ -76,6 +77,7 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`.
| `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas |
| `/api/conciliacoes-fornecedores-contas/{id}/vincular/` | POST | `{lancamentos: [ids]}`: vínculo manual; exige itens sem vínculo, da própria conta, com pelo menos um débito e um crédito. Créditos maiores que débitos **além da tolerância** da conciliação viram `tipo="parcial"` (pagamento parcial: a `diferenca` continua em aberto, entra na distribuição como um título com a data do crédito mais antigo do vínculo e conta para o aging); dentro da tolerância, ou pago a maior, `tipo="manual"` (fecha, com alerta de diferença) |
| `/api/conciliacoes-fornecedores-contas/{id}/validar/` | POST | `{validada, observacao}` |
| `/api/conciliacoes-fornecedores-contas/{id}/historico-vinculos/` | GET | histórico das ações manuais sobre vínculos da conta, mais recente primeiro (botão "i" do razão) |
| `/api/conciliacoes-fornecedores-vinculos/{id}/` | DELETE | desfaz o vínculo |
| `/api/conciliacoes-fornecedores-vinculos/{id}/adicionar/` | POST | `{lancamentos: [ids]}` (1 ou mais): acrescenta lançamentos pendentes da mesma conta a um vínculo **manual ou parcial** (automático é 400), sem desfazer. Tipo e diferença recalculados por `_conciliacao_tipo_vinculo_manual()`, a mesma regra da criação (pode continuar parcial, fechar ou virar pago a maior); `criado_por` passa a ser quem acrescentou |
@ -88,9 +90,13 @@ Duas vistas na mesma página. **Histórico**: formulário de nova conciliação
- **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e filtra), 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 números por situação ficam só nos chips** (Todos/Divergentes/Atenção/Pendentes/Conciliados/Validados), que também filtram: não duplicar em card;
- **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar;
- **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar; depois de qualquer ajuste numa conta (validar, vincular, desfazer), `aplicarRespostaConta()` reaplica o filtro (`contaPassaFiltro()`, a mesma regra de `contasFiltradas()`) e, se a conta não couber mais, ela sai na hora com um fade (`removerConta()`), sem esperar o próximo render da lista;
- filtros e cabeçalho da tabela **fixos ao rolar** (`position: sticky` abaixo da `.topbar`; `.conc-table-wrap` desliga o `overflow:hidden` de `.pa-table-wrap`, que anularia o sticky);
- linhas compactas, com o fornecedor e o primeiro alerta cortados com reticências e a situação só como etiqueta.
- nas sub-tabelas de lançamentos, **histórico, contrapartida e sugestão ficam em uma linha cada**, cortados com reticências e com o texto completo num tooltip no visual do Portal (`[data-conc-tooltip]` + `.conc-tooltip`, único elemento `fixed` no body, só aparece quando o texto está cortado; **nunca `title` nativo**, pedido do usuário) (`pidConcHistoricoHtml()`/`pidConcSituacaoHtml()`; `max-width: 0` + largura em % nas colunas Histórico/Situação), para a altura da linha não variar com a largura da tela.
- **"i" pequeno em cada linha vinculada** do razão (ao lado de "Desfazer"): abre o detalhe do vínculo (`pidConcAbreDetalheVinculo()`, só frontend, com o payload já carregado): tipo, automático/manual, o **critério** que o motor usou (`PID_CONC_CRITERIO_VINCULO`, texto por tipo; se mudar uma regra do `motor.py`, atualizar o texto), os lançamentos com histórico/contrapartida e os totais de créditos/débitos com a diferença. Mesmo modal do histórico (`pidConcAbreModalInfo()`).
- botão **"i"** no canto direito da barra do razão abre o modal "Histórico de vinculações" da conta (`pidConcAbreHistoricoVinculos()`, criado sob demanda no body): cada ação com quem, quando, tipo e os seq. envolvidos, com histórico e contrapartida de cada um (lidos na hora pelo id no endpoint, não gravados na cópia) e os acrescentados marcados, borda verde/roxa/vermelha para vinculou/acrescentou/desfez.
- passar o mouse na **sugestão** destaca em dourado a própria linha e as do outro lado da sugestão (`situacao.relacionados` no payload, id negativo = saldo de vínculo parcial; classe `is-sugestao-grupo`), no mesmo espírito do destaque verde do vínculo. **Duplo clique na sugestão** marca a linha e as relacionadas não vinculadas (pelo `change` dos checkboxes, então soma e "Vincular selecionados" acompanham); se já estavam todas marcadas, desmarca. Por isso o clique simples dentro da sugestão não marca/desmarca a linha (dispararia duas vezes antes do duplo clique).
- na conta expandida, a **barra de seleção** (soma de créditos/débitos, diferença e "Vincular selecionados") e o thead da lista de pendentes também ficam fixos, empilhados abaixo do thead da tabela de fornecedores (`--conc-thead-altura`/`--conc-selecao-altura` no `#conc-detail-view`; se a altura de um deles mudar, ajustar a variável). A `.conc-subtabela--pendentes` desliga o `overflow:hidden` pelo mesmo motivo de `.conc-table-wrap`.
A tabela de fornecedores, **ordenada por padrão pela situação, divergentes primeiro** (depois atenção, pendente, conciliada) (conta, fornecedor, saldo inicial, saldo residual, em aberto há mais de 3 meses, situação com ícone, e um **checkbox "Validado" no fim da linha**, pedido do usuário). O checkbox grava direto pelo mesmo `validar/` do botão "Marcar como validada" do detalhe, sem enviar `observacao` (o servidor mantém a já registrada), e o clique nele não expande a linha. **Quando a conta passa a validada** (por qualquer caminho, inclusive a validação automática), a linha expandida recolhe. Clicar numa linha expande: alertas, o **razão desde a primeira pendência** (todos os lançamentos, na ordem do arquivo e com a coluna de saldo do Questor, a partir da data do lançamento pendente mais antigo, incluindo os itens de pagamento parcial; os já vinculados aparecem apagados como "Vinculado #N" com o tipo, sem seleção, destacando o vínculo inteiro ao passar o mouse e com "Desfazer" na própria linha, para achar vínculo automático errado; um toggle **"Ocultar conciliados"** ao lado do título, **desmarcado por padrão** (pedido do usuário), esconde essas linhas vinculadas só por CSS, e a escolha fica em `estadoDe(conta.id).ocultarConciliados`, sobrevivendo ao re-render depois de vincular/desfazer), os **lançamentos não conciliados** (com checkbox para vínculo manual, valor em aberto e situação de cada um), lançamentos conciliados agrupados por vínculo (ocultos por padrão, com "Desfazer vínculo") e observação/validação da conta.

View File

@ -6,9 +6,11 @@ porque o contador pode desfazer ou criar vínculos a qualquer momento.
título sem vínculo está em aberto pelo **valor cheio**, e um pagamento sem
vínculo é "pagamento não vinculado", mesmo que a ferramenta desconfie de
qual título ele paga. Essa desconfiança sai numa distribuição dos
pagamentos não vinculados sobre os títulos em aberto (primeiro o título do
pagamentos não vinculados sobre os títulos em aberto (antes de tudo, os
pares de mesmo valor, com o desempate do motor; depois o título do
qual o pagamento parece ser parcela, título ≈ k × pagamento com k de 2 a
`MAX_PARCELAS`; senão o mais antigo com saldo), usada **só** para:
`MAX_PARCELAS`, primeiro a de arredondamento exato e depois a com pequena
diferença, ver `_parcela`; senão o mais antigo com saldo), usada **só** para:
- escrever a sugestão de cada item (`SituacaoItem.sugestao`, texto à parte
da `descricao`, que é só fato);
@ -28,9 +30,9 @@ from __future__ import annotations
import unicodedata
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from decimal import ROUND_DOWN, Decimal
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA, TOLERANCIA_PERCENTUAL_PADRAO, Tolerancia
MESES_VENCIDO = 3
MAX_PARCELAS = 24
@ -126,22 +128,45 @@ def _soma_meses(data: date, meses: int) -> date:
return date(ano, mes, min(data.day, dias_no_mes[mes - 1]))
def _eh_parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> bool:
def _parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> tuple[int, Decimal, Decimal] | None:
"""Se o pagamento parece parcela do título (título ≈ k × pagamento, k de
2 a `MAX_PARCELAS`), devolve (k, parcela de referência, diferença do
pagamento para ela); senão None. `k` vai no texto da sugestão ("em 3x").
Diferença zero: bate com o arredondamento da divisão (até 1 centavo por
parcela no total). Diferença não zero: o pagamento fica a até
`TOLERANCIA_PERCENTUAL_PADRAO`% da parcela arredondada mais próxima
(possível juros/desconto), e a sugestão mostra quanto (pedido do usuário:
um pagamento de R$ 2.163,57 de um título de R$ 8.654,22 em 4 parcelas
não era reconhecido como parcela). Só o percentual, sem o piso fixo de
`TOLERANCIA_VALOR_PADRAO`, que em parcela pequena aceitaria quase tudo.
"""
if valor_pagamento <= 0 or valor_pagamento >= valor_titulo:
return False
return None
k = round(valor_titulo / valor_pagamento)
if k < 2 or k > MAX_PARCELAS:
return False
# arredondamento de parcela: até 1 centavo por parcela
return abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k
return None
if abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k:
return k, valor_pagamento, Decimal("0")
parcela_baixa = (valor_titulo / k).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
referencia = min((parcela_baixa, parcela_baixa + Decimal("0.01")), key=lambda p: abs(valor_pagamento - p))
diferenca = valor_pagamento - referencia
if abs(diferenca) <= referencia * TOLERANCIA_PERCENTUAL_PADRAO / Decimal(100):
return k, referencia, diferenca
return None
def _rotulo_titulo(credito: ItemAnalise) -> str:
return f"{credito.data:%d/%m/%Y} ({_moeda(credito.valor)})"
def _natureza_diferenca(diferenca: Decimal) -> str:
"""`diferenca` = título - pagamento (mesma convenção do vínculo)."""
return "a menos (possível desconto)" if diferenca > 0 else "a mais (possível juros/multa)"
def _analisa_pendentes(
itens: list[ItemAnalise], data_base: date
itens: list[ItemAnalise], data_base: date, tolerancia: Tolerancia | None = None
) -> tuple[dict[int, SituacaoItem], Decimal, Decimal, Decimal, Decimal]:
creditos = sorted((i for i in itens if i.natureza == "C"), key=lambda i: (i.data, i.id))
debitos = sorted((i for i in itens if i.natureza == "D"), key=lambda i: (i.data, i.id))
@ -154,19 +179,102 @@ def _analisa_pendentes(
total_nao_vinculado = Decimal("0")
total_excedente = Decimal("0")
# 1º: pagamento de mesmo valor de um título, antes de tudo e antes de
# percorrer os pagamentos por data (senão um pagamento anterior poderia
# abater, pelo "mais antigo com saldo", o título do par exato de outro).
# Mesmo desempate do motor (`_pareia_exato`): data mais próxima, depois
# pagamento após o título antes de pagamento adiantado. Pedido do usuário:
# ao desfazer um vínculo automático exato, a sugestão apontava outro título.
candidatos_exatos = sorted(
(
(abs((c.data - d.data).days), 1 if d.data < c.data else 0, c.data, c.id, d.id, c, d)
for c in creditos
for d in debitos
if c.valor == d.valor
),
key=lambda t: t[:5],
)
par_exato: dict[int, ItemAnalise] = {} # id do débito -> título
creditos_pareados: set[int] = set()
for *_, credito, debito in candidatos_exatos:
if debito.id in par_exato or credito.id in creditos_pareados:
continue
par_exato[debito.id] = credito
creditos_pareados.add(credito.id)
saldo_sugerido[credito.id] = Decimal("0")
pagos_sugeridos[credito.id].append(debito.id)
# 2º: valor próximo, dentro da tolerância da conciliação (possível
# juros/desconto). Antes era vínculo automático do motor; o usuário pediu
# que vire só sugestão, apontando a possibilidade de juros/desconto. 1:1,
# menor diferença primeiro, depois data mais próxima.
par_proximo: dict[int, ItemAnalise] = {} # id do débito -> título
if tolerancia:
candidatos_proximos = sorted(
(
(abs(c.valor - d.valor), abs((c.data - d.data).days), c.id, d.id, c, d)
for c in creditos
if c.id not in creditos_pareados
for d in debitos
if d.id not in par_exato
and abs(c.valor - d.valor) * 100 <= tolerancia.limite_centavos(int(c.valor * 100))
),
key=lambda t: t[:4],
)
for *_, credito, debito in candidatos_proximos:
if debito.id in par_proximo or credito.id in creditos_pareados:
continue
par_proximo[debito.id] = credito
creditos_pareados.add(credito.id)
saldo_sugerido[credito.id] = Decimal("0")
pagos_sugeridos[credito.id].append(debito.id)
titulo_proximo = {credito.id: debito_id for debito_id, credito in par_proximo.items()}
por_id_debito = {d.id: d for d in debitos}
for debito in debitos:
if debito.id in par_proximo:
credito = par_proximo[debito.id]
diferenca = credito.valor - debito.valor
total_nao_vinculado += debito.valor
situacoes[debito.id] = SituacaoItem(
CATEGORIA_NAO_VINCULADO,
Decimal("0"),
"Pagamento não vinculado.",
f"Possível pagamento do título de {_rotulo_titulo(credito)}, com {_moeda(diferenca)} "
f"{_natureza_diferenca(diferenca)}. Vincule para confirmar.",
[credito.id],
)
continue
if debito.id in par_exato:
credito = par_exato[debito.id]
total_nao_vinculado += debito.valor
situacoes[debito.id] = SituacaoItem(
CATEGORIA_NAO_VINCULADO,
Decimal("0"),
"Pagamento não vinculado.",
f"Mesmo valor do título de {_rotulo_titulo(credito)}. Vincule para confirmar.",
[credito.id],
)
continue
restante = debito.valor
usados: list[int] = []
parcela_de = next(
(c for c in creditos if saldo_sugerido[c.id] >= debito.valor and _eh_parcela(c.valor, debito.valor)),
None,
)
# Parcela exata (arredondamento) antes da com diferença, em qualquer título.
candidatas = [
(c, parcela)
for c in creditos
if saldo_sugerido[c.id] >= debito.valor and (parcela := _parcela(c.valor, debito.valor))
]
escolhida = next((p for p in candidatas if p[1][2] == 0), candidatas[0] if candidatas else None)
parcela_de = escolhida[0] if escolhida else None
ordem = ([parcela_de] if parcela_de else []) + [c for c in creditos if c is not parcela_de]
disponivel_usado = Decimal("0") # saldo dos títulos usados antes deste pagamento
for credito in ordem:
if restante <= 0:
break
if saldo_sugerido[credito.id] <= 0:
continue
disponivel_usado += saldo_sugerido[credito.id]
abatido = min(restante, saldo_sugerido[credito.id])
saldo_sugerido[credito.id] -= abatido
restante -= abatido
@ -174,10 +282,36 @@ def _analisa_pendentes(
pagos_sugeridos[credito.id].append(debito.id)
titulos = [c for c in creditos if c.id in usados]
if parcela_de and usados == [parcela_de.id]:
sugestao = f"Possível parcela do título de {_rotulo_titulo(parcela_de)}. Vincule para confirmar."
if escolhida and usados == [parcela_de.id]:
qtd_parcelas, referencia, diferenca = escolhida[1]
# Quantidade de parcelas no texto (pedido do usuário).
if diferenca == 0:
sugestao = (
f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de "
f"{_moeda(debito.valor)}. Vincule para confirmar."
)
else:
sentido = "a mais" if diferenca > 0 else "a menos"
sugestao = (
f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de "
f"{_moeda(referencia)}, com {_moeda(diferenca)} {sentido} (juros/desconto?). Vincule para confirmar."
)
elif titulos:
sugestao = f"Pode ser pagamento do(s) título(s) de {', '.join(_rotulo_titulo(c) for c in titulos)}."
# Sem par de valor nem parcela: só a ordem de antiguidade, sugestão
# fraca, e o texto deixa isso claro (pedido do usuário).
sugestao = (
"Sem título de valor correspondente; o(s) mais antigo(s) em aberto: "
f"{', '.join(_rotulo_titulo(c) for c in titulos)}."
)
# Vários títulos: soma e o que sobra (pedido do usuário). A soma é
# do saldo ainda disponível de cada um, que pode ser menor que o
# valor do título se um pagamento anterior já o abateu em parte.
if len(titulos) > 1:
if restante > 0:
residual = f"o pagamento excede em {_moeda(restante)}"
else:
residual = f"saldo residual de {_moeda(disponivel_usado - debito.valor)} em aberto"
sugestao += f" Soma: {_moeda(disponivel_usado)}; {residual}."
else:
sugestao = ""
@ -202,6 +336,13 @@ def _analisa_pendentes(
coberto = credito.valor - saldo_sugerido[credito.id]
if not pagos:
sugestao = ""
elif credito.id in titulo_proximo:
debito = por_id_debito[titulo_proximo[credito.id]]
diferenca = credito.valor - debito.valor
sugestao = (
f"Possível pagamento em {debito.data:%d/%m/%Y} ({_moeda(debito.valor)}), com {_moeda(diferenca)} "
f"{_natureza_diferenca(diferenca)}. Vincule para confirmar."
)
elif saldo_sugerido[credito.id] <= 0:
sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar."
else:
@ -226,9 +367,11 @@ def analisa_conta(
saldo_final: Decimal,
saldo_final_arquivo: Decimal,
data_base: date,
tolerancia: Tolerancia | None = None,
) -> AnaliseConta:
"""`saldo_final` é o recalculado (saldo anterior + créditos - débitos);
`saldo_final_arquivo` o último impresso pelo Questor. Credor positivo."""
`saldo_final_arquivo` o último impresso pelo Questor. Credor positivo.
`tolerancia` (a da conciliação) só alimenta a sugestão de valor próximo."""
pendentes = [i for i in itens if i.vinculo_id is None]
# O saldo de cada pagamento parcial vinculado à mão continua em aberto:
# entra como um título a mais (id negativo = -id do vínculo), com a data
@ -246,7 +389,7 @@ def analisa_conta(
)
)
situacoes, total_aberto, total_vencido, total_nao_vinculado, total_excedente = _analisa_pendentes(
pendentes, data_base
pendentes, data_base, tolerancia
)
alertas: list[AlertaConta] = []

View File

@ -18,12 +18,23 @@ ordem:
créditos, como NF + juros, somando um débito), limitada a
`MAX_ITENS_COMBINACAO` itens numa janela de `JANELA_DIAS_COMBINACAO`
dias, com teto de nós visitados por busca (subset-sum é exponencial).
4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou
4. **Quitação dos títulos mais antigos**: para cada pagamento ainda
pendente, em ordem de data, se os títulos mais antigos em aberto (com
data até a do pagamento), somados em sequência, fecham **exatamente** o
pagamento, viram um vínculo. Não é um subconjunto qualquer (há uma única
sequência possível), então não tem o limite de itens nem de candidatos
da combinação. Pedido do usuário (2026-09-25): pagamentos de fretes
acumulados (BRASPRESS: R$ 234,40 = 4 títulos, R$ 616,82 = 7 títulos) que
a combinação não achava, por os títulos estarem fora dos 12 mais
próximos em data.
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou
o saldo se compensa por definição; vira um único vínculo.
5. **Tolerância** (só no último segmento, o que ficou em aberto): 1:1 ou
combinação pequena com diferença até o limite configurado (valor fixo
**ou** percentual sobre o valor do título, o que for maior), sinalizada
como possível juros/desconto.
A diferença dentro da tolerância (possível juros/desconto) **não** vira
vínculo automático (pedido do usuário, 2026-09-25: antes era a etapa 5, 1:1
ou 2 itens no último segmento): o par fica pendente e `alertas.py` o aponta
como sugestão, para o contador confirmar com um vínculo manual.
`TIPO_TOLERANCIA`/`_combina(..., tolerancia)` continuam existindo pelos
vínculos gravados antes disso.
O que sobrar fica pendente, sem vínculo gravado. `alertas.py` distribui os
débitos pendentes sobre os créditos pendentes (parcelas, pagamento parcial)
@ -55,6 +66,7 @@ TOLERANCIA_PERCENTUAL_PADRAO = Decimal("2.00")
TIPO_EXATO = "exato"
TIPO_COMBINACAO = "combinacao"
TIPO_SEQUENCIA = "sequencia" # quitação dos títulos mais antigos (etapa 4)
TIPO_SALDO_ZERADO = "saldo_zerado"
TIPO_TOLERANCIA = "tolerancia"
TIPO_MANUAL = "manual"
@ -247,12 +259,35 @@ def _combina(
return vinculos
def _quita_mais_antigos(pendentes: list[ItemConciliacao]) -> list[VinculoSugerido]:
"""Etapa 4 (ver topo do módulo): cada pagamento, em ordem de data, contra
a sequência dos títulos mais antigos ainda em aberto, só se a soma fechar
exatamente (2 títulos ou mais; 1:1 já é da etapa de valor exato)."""
creditos = sorted((i for i in pendentes if i.natureza == "C"), key=lambda i: (i.data, i.indice))
debitos = sorted((i for i in pendentes if i.natureza == "D"), key=lambda i: (i.data, i.indice))
vinculos: list[VinculoSugerido] = []
for debito in debitos:
soma = 0
grupo: list[ItemConciliacao] = []
for credito in creditos:
if credito.data > debito.data or soma >= debito.centavos:
break
soma += credito.centavos
grupo.append(credito)
if soma != debito.centavos or len(grupo) < 2:
continue
vinculos.append(_vinculo(TIPO_SEQUENCIA, [*grupo, debito]))
usados = {c.indice for c in grupo}
creditos = [c for c in creditos if c.indice not in usados]
return vinculos
def _sem_vinculo(itens: list[ItemConciliacao], vinculos: list[VinculoSugerido]) -> list[ItemConciliacao]:
usados = {indice for v in vinculos for indice in v.indices}
return [i for i in itens if i.indice not in usados]
def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[VinculoSugerido]:
def concilia(itens: list[ItemConciliacao]) -> list[VinculoSugerido]:
"""Roda as etapas descritas no topo do módulo para uma conta. `itens`
precisa estar na ordem do arquivo (é ela que define o saldo corrente)."""
vinculos: list[VinculoSugerido] = []
@ -263,11 +298,9 @@ def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[Vincu
aberto = posicao == len(segmentos) - 1
locais = _pareia_exato(segmento)
locais += _combina(_sem_vinculo(segmento, locais), TIPO_COMBINACAO, MAX_ITENS_COMBINACAO, None)
locais += _quita_mais_antigos(_sem_vinculo(segmento, locais))
restantes = _sem_vinculo(segmento, locais)
if not aberto:
if restantes:
if not aberto and restantes:
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
else:
locais += _combina(restantes, TIPO_TOLERANCIA, MAX_ITENS_TOLERANCIA, tolerancia)
vinculos += locais
return vinculos

View File

@ -0,0 +1,34 @@
# Generated by Django 6.0.7 on 2026-09-25 12:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0083_conciliacao_vinculo_parcial'),
]
operations = [
migrations.CreateModel(
name='ConciliacaoFornecedorVinculoHistorico',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('acao', models.CharField(choices=[('vinculou', 'Vinculou'), ('acrescentou', 'Acrescentou ao vínculo'), ('desfez', 'Desfez o vínculo')], max_length=20, verbose_name='Ação')),
('vinculo_id_original', models.IntegerField(blank=True, null=True, verbose_name='Vínculo (id)')),
('tipo_rotulo', models.CharField(blank=True, max_length=60, verbose_name='Tipo do vínculo')),
('vinculo_automatico', models.BooleanField(default=False, verbose_name='Vínculo automático')),
('lancamentos', models.JSONField(default=list, verbose_name='Lançamentos envolvidos')),
('criado_em', models.DateTimeField(auto_now_add=True)),
('conta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='historico_vinculos', to='portal_api.conciliacaofornecedorconta')),
('usuario', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Histórico de vínculo (Conciliação de fornecedores)',
'verbose_name_plural': 'Histórico de vínculos (Conciliação de fornecedores)',
'ordering': ['-criado_em', '-id'],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-25 14:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0084_conciliacao_historico_vinculos'),
]
operations = [
migrations.AlterField(
model_name='conciliacaofornecedorvinculo',
name='tipo',
field=models.CharField(choices=[('exato', 'Valor exato'), ('combinacao', 'Combinação de valores'), ('sequencia', 'Quitação dos títulos mais antigos'), ('saldo_zerado', 'Compensação até saldo zerado'), ('tolerancia', 'Com diferença (juros/desconto)'), ('manual', 'Manual'), ('parcial', 'Pagamento parcial')], max_length=20, verbose_name='Tipo'),
),
]

View File

@ -2769,6 +2769,7 @@ class ConciliacaoFornecedorVinculo(models.Model):
TIPO_CHOICES = [
("exato", "Valor exato"),
("combinacao", "Combinação de valores"),
("sequencia", "Quitação dos títulos mais antigos"),
("saldo_zerado", "Compensação até saldo zerado"),
("tolerancia", "Com diferença (juros/desconto)"),
("manual", "Manual"),
@ -2822,3 +2823,39 @@ class ConciliacaoFornecedorLancamento(models.Model):
def __str__(self) -> str:
return f"{self.data:%d/%m/%Y} {self.natureza} {self.valor}"
class ConciliacaoFornecedorVinculoHistorico(models.Model):
"""Registro de cada ação manual sobre vínculos de uma conta (vincular,
acrescentar lançamentos, desfazer — inclusive o desfazer de um vínculo
automático), para o contador saber quem vinculou/desvinculou o quê
(pedido do usuário). `lancamentos` é uma cópia dos lançamentos
envolvidos no momento da ação (seq., data, natureza, valor), porque o
vínculo desfeito é apagado e `vinculo_id` deixa de existir."""
ACAO_VINCULOU = "vinculou"
ACAO_ACRESCENTOU = "acrescentou"
ACAO_DESFEZ = "desfez"
ACAO_CHOICES = [
(ACAO_VINCULOU, "Vinculou"),
(ACAO_ACRESCENTOU, "Acrescentou ao vínculo"),
(ACAO_DESFEZ, "Desfez o vínculo"),
]
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="historico_vinculos")
acao = models.CharField("Ação", max_length=20, choices=ACAO_CHOICES)
vinculo_id_original = models.IntegerField("Vínculo (id)", null=True, blank=True)
tipo_rotulo = models.CharField("Tipo do vínculo", max_length=60, blank=True)
vinculo_automatico = models.BooleanField("Vínculo automático", default=False)
# [{"id", "sequencia", "data", "natureza", "valor", "eh_saldo_anterior", "acrescentado"}]
lancamentos = models.JSONField("Lançamentos envolvidos", default=list)
usuario = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
criado_em = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Histórico de vínculo (Conciliação de fornecedores)"
verbose_name_plural = "Histórico de vínculos (Conciliação de fornecedores)"
ordering = ["-criado_em", "-id"]
def __str__(self) -> str:
return f"{self.get_acao_display()} #{self.vinculo_id_original} ({self.criado_em:%d/%m/%Y %H:%M})"

View File

@ -65,6 +65,7 @@ from .models import (
ConciliacaoFornecedorConta,
ConciliacaoFornecedorLancamento,
ConciliacaoFornecedorVinculo,
ConciliacaoFornecedorVinculoHistorico,
ContabilAchado,
ContabilApuracao,
ContabilApuracaoReprocessamento,
@ -5301,6 +5302,7 @@ def _conciliacao_analisa(
saldo_final=conta.saldo_final,
saldo_final_arquivo=conta.saldo_final_arquivo,
data_base=conciliacao.data_base,
tolerancia=conciliacao_motor.Tolerancia(conciliacao.tolerancia_valor, conciliacao.tolerancia_percentual),
)
@ -5337,6 +5339,7 @@ def _conciliacao_conta_payload(
"valor_em_aberto": str(item.valor_em_aberto),
"descricao": item.descricao,
"sugestao": item.sugestao,
"relacionados": item.relacionados,
}
def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None:
@ -5348,6 +5351,9 @@ def _conciliacao_conta_payload(
"valor_em_aberto": str(item.valor_em_aberto),
"descricao": item.descricao,
"sugestao": item.sugestao,
# ids do outro lado da sugestão (id negativo = saldo de um vínculo
# parcial, `-vinculo.id`), para a tela destacar as linhas no hover
"relacionados": item.relacionados,
}
return {
@ -5495,7 +5501,6 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va
def _conciliacao_grava_contas(
conciliacao: ConciliacaoFornecedor,
razao: conciliacao_parser.RazaoExtraido,
tolerancia: conciliacao_motor.Tolerancia,
) -> None:
"""Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo
anterior diferente de zero vira um lançamento `eh_saldo_anterior`,
@ -5554,8 +5559,7 @@ def _conciliacao_grava_contas(
[
conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor)
for pos, linha in enumerate(linhas)
],
tolerancia,
]
)
vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create(
[ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos]
@ -5705,7 +5709,7 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
)
conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
conciliacao.save()
_conciliacao_grava_contas(conciliacao, razao, tolerancia)
_conciliacao_grava_contas(conciliacao, razao)
_conciliacao_valida_conciliadas(conciliacao)
_conciliacao_atualiza_resumo(conciliacao)
except Exception:
@ -5759,6 +5763,38 @@ def _conciliacao_tipo_vinculo_manual(
return tipo, diferenca
def _conciliacao_registra_historico(
conta: ConciliacaoFornecedorConta,
acao: str,
vinculo: ConciliacaoFornecedorVinculo,
lancs: list[ConciliacaoFornecedorLancamento],
usuario: Usuario,
acrescentados: set[int] | None = None,
) -> None:
"""Grava uma ação manual sobre vínculo no histórico da conta, com a cópia
dos lançamentos envolvidos (o vínculo desfeito some do banco)."""
ConciliacaoFornecedorVinculoHistorico.objects.create(
conta=conta,
acao=acao,
vinculo_id_original=vinculo.id,
tipo_rotulo=vinculo.get_tipo_display(),
vinculo_automatico=vinculo.tipo not in (conciliacao_motor.TIPO_MANUAL, conciliacao_motor.TIPO_PARCIAL),
lancamentos=[
{
"id": lanc.id,
"sequencia": lanc.sequencia,
"data": lanc.data.isoformat(),
"natureza": lanc.natureza,
"valor": str(lanc.valor),
"eh_saldo_anterior": lanc.eh_saldo_anterior,
"acrescentado": bool(acrescentados and lanc.id in acrescentados),
}
for lanc in sorted(lancs, key=lambda l: (l.ordem, l.id))
],
usuario=usuario,
)
class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
"""Ajustes manuais numa conta: vincular lançamentos pendentes e marcar
a conta como validada (com observação)."""
@ -5787,8 +5823,46 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user
)
ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo)
_conciliacao_registra_historico(
conta, ConciliacaoFornecedorVinculoHistorico.ACAO_VINCULOU, vinculo, lancs, request.user
)
return _conciliacao_resposta_conta(conta)
@action(detail=True, methods=["get"], url_path="historico-vinculos")
def historico_vinculos(self, request: Request, pk: str | None = None) -> Response:
"""Histórico das ações manuais sobre vínculos da conta, mais recente
primeiro (botão "i" do razão)."""
conta = self.get_object()
registros = list(conta.historico_vinculos.select_related("usuario"))
# Histórico e contrapartida lidos na hora pelo id (o lançamento nunca
# muda depois da importação), para valer também nos registros que já
# existiam antes de a tela mostrá-los (pedido do usuário).
ids = {lanc["id"] for r in registros for lanc in r.lancamentos}
textos = {
lanc.id: (lanc.historico, _conciliacao_rotulo_contrapartida(lanc))
for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta=conta, id__in=ids)
}
def com_texto(lanc: dict[str, Any]) -> dict[str, Any]:
historico, contrapartida = textos.get(lanc["id"], ("", ""))
return {**lanc, "historico": historico, "contrapartida": contrapartida}
return Response(
[
{
"id": r.id,
"acao": r.acao,
"acao_rotulo": r.get_acao_display(),
"tipo_rotulo": r.tipo_rotulo,
"vinculo_automatico": r.vinculo_automatico,
"lancamentos": [com_texto(lanc) for lanc in r.lancamentos],
"usuario_nome": r.usuario.nome if r.usuario else None,
"criado_em": r.criado_em.isoformat(),
}
for r in registros
]
)
@action(detail=True, methods=["post"])
def validar(self, request: Request, pk: str | None = None) -> Response:
conta = self.get_object()
@ -5817,6 +5891,13 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
vinculo = self.get_object()
conta = vinculo.conta
with transaction.atomic():
_conciliacao_registra_historico(
conta,
ConciliacaoFornecedorVinculoHistorico.ACAO_DESFEZ,
vinculo,
list(vinculo.lancamentos.all()),
request.user,
)
vinculo.delete()
return _conciliacao_resposta_conta(conta)
@ -5844,4 +5925,7 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos)
vinculo.criado_por = request.user
vinculo.save(update_fields=["tipo", "diferenca", "criado_por"])
_conciliacao_registra_historico(
conta, ConciliacaoFornecedorVinculoHistorico.ACAO_ACRESCENTOU, vinculo, todos, request.user, ids
)
return _conciliacao_resposta_conta(conta)

View File

@ -662,16 +662,242 @@ button.conc-kpi.is-active {
width: 28px;
}
.conc-historico {
max-width: 380px;
/* Histórico e sugestão em uma linha só, cortados com reticências (texto
completo no `title`), para a altura da linha não variar com a largura da
tela (pedido do usuário). `max-width: 0` + largura em % faz a célula ceder
espaço em vez de alargar a tabela, igual à coluna de fornecedor. */
.conc-subtabela td.conc-historico {
max-width: 0;
width: 30%;
}
.conc-historico__texto,
.conc-historico__contrapartida {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conc-historico__contrapartida {
display: block;
font-size: 0.72rem;
color: var(--text-muted);
}
.conc-subtabela td.conc-situacao {
max-width: 0;
width: 34%;
}
.conc-subtabela td.conc-situacao .conc-sugestao {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Conta saindo da lista por não caber mais no filtro (ex.: acabou de ser
validada). `removerConta()` tira a linha ao fim da animação. */
.conc-table tbody tr.is-saindo {
animation: pidConcSaida 160ms ease-in forwards;
pointer-events: none;
}
@keyframes pidConcSaida {
to {
opacity: 0;
}
}
/* Botão "i" no canto da barra do razão: abre o histórico de vinculações. */
.conc-info-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border-subtle);
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.conc-info-btn:hover,
.conc-info-btn:focus-visible {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.55);
}
.conc-info-btn:active {
transform: scale(0.94);
}
/* Versão menor, na linha vinculada do razão, ao lado do "Desfazer". */
.conc-info-btn--linha {
width: 20px;
height: 20px;
margin-left: 2px;
vertical-align: middle;
}
.conc-info-btn--linha svg {
width: 13px;
height: 13px;
}
/* Modal do detalhe de um vínculo. */
.conc-vinculo-detalhe__criterio {
margin: 0 0 var(--space-2);
font-size: 0.78rem;
color: var(--text-secondary);
}
.conc-vinculo-detalhe__totais {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
font-size: 0.8rem;
color: var(--text-secondary);
}
.conc-vinculo-detalhe__totais strong {
color: var(--text-primary);
}
/* Modal "Histórico de vinculações". */
.conc-historico-vinc {
max-height: 60vh;
overflow-y: auto;
}
.conc-historico-vinc__lista {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.conc-historico-vinc__item {
border: 1px solid var(--border-subtle);
border-left: 3px solid #2f9e6e;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.conc-historico-vinc__item--acrescentou {
border-left-color: var(--accent);
}
.conc-historico-vinc__item--desfez {
border-left-color: var(--danger);
}
.conc-historico-vinc__cabecalho {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2);
font-size: 0.85rem;
margin-bottom: var(--space-1);
}
.conc-historico-vinc__tipo {
font-size: 0.78rem;
color: var(--text-secondary);
}
.conc-historico-vinc__quem {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-muted);
}
.conc-historico-vinc__lancs {
list-style: none;
margin: 0;
padding: 0;
}
/* seq. | data | natureza | histórico (+ contrapartida) | valor | "acrescentado" */
.conc-historico-vinc__lanc {
display: grid;
grid-template-columns: 90px 80px 64px minmax(0, 1fr) 100px auto;
align-items: center;
gap: var(--space-2);
padding: 3px 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.conc-historico-vinc__lanc + .conc-historico-vinc__lanc {
border-top: 1px solid var(--border-subtle);
}
/* `min-width: 0` para as reticências de histórico/contrapartida valerem
dentro da coluna flexível do grid. */
.conc-historico-vinc__texto {
min-width: 0;
}
.conc-historico-vinc__texto .conc-historico__texto {
color: var(--text-primary);
}
.conc-historico-vinc__lanc.is-acrescentado {
color: var(--text-primary);
}
.conc-historico-vinc__seq {
font-weight: 600;
color: var(--text-primary);
}
.conc-historico-vinc__novo {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
color: var(--accent);
}
.conc-historico-vinc__vazio {
margin: var(--space-3) 0;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Texto completo do que foi cortado acima, no visual do Portal (mesmo de
`.dc-hover-tooltip__texto`). Criado por `pidConcMostraTooltip()`. */
.conc-tooltip {
position: fixed;
/* acima de `.modal-overlay` (100) e do modal de confirmação (150): também
é usado no modal "Histórico de vinculações" */
z-index: 200;
width: max-content;
max-width: 420px;
padding: var(--space-2) var(--space-3);
background: var(--bg-surface-raised);
color: var(--text-primary);
/* Borda na cor do tema + sombra média: com a borda neutra e a sombra
leve de `.dc-hover-tooltip__texto`, o balão se confundia com o fundo
escuro da tabela (pedido do usuário). */
border: 1px solid rgba(var(--accent-rgb), 0.55);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
font-size: 0.75rem;
font-style: normal;
line-height: 1.4;
white-space: normal;
pointer-events: none;
animation: pidFadeIn 120ms ease-out;
}
.conc-natureza {
font-size: 0.7rem;
font-weight: 700;
@ -1015,6 +1241,15 @@ button.conc-kpi.is-active {
color: var(--text-primary);
}
/* Hover na sugestão: a linha e as do outro lado da sugestão. Dourado (a
cor das situações "em aberto há mais de 3 meses"/"pagamento não
vinculado"): não o verde de `.is-grupo`, porque a sugestão ainda não é
vínculo, nem o roxo, que já é o de linha selecionada (`.is-selecionado`). */
.pa-table .conc-subtabela--pendentes tbody tr.is-sugestao-grupo td {
background: rgba(var(--gold-rgb), 0.1);
color: var(--text-primary);
}
.conc-valor--saldo {
color: var(--text-secondary);
}

View File

@ -122,7 +122,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<li class="ag-row${canManage ? " ag-row--draggable" : ""}" data-acesso-id="${acesso.id}" ${canManage ? 'draggable="true"' : ""}>
<button type="button" class="ag-row__open" data-acesso-open="${acesso.id}">
${PID_AG_ROW_ICON}
<span class="ag-row__label">${acesso.nome}</span>
<span class="ag-row__label">${pidEscapeHtml(acesso.nome)}</span>
</button>
<div class="ag-row__admin">${adminHtml}</div>
</li>`;
@ -156,7 +156,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return `
<section class="ag-section" data-secao-id="${secao.id}">
<div class="ag-section__header">
<h3 class="ag-section__title">${secao.nome}</h3>
<h3 class="ag-section__title">${pidEscapeHtml(secao.nome)}</h3>
${actionsHtml}
</div>
<ul class="ag-row-list" data-secao-list="${secao.id}">${rowsHtml}</ul>
@ -200,7 +200,7 @@ document.addEventListener("DOMContentLoaded", async () => {
(p) => `
<label class="checklist-item">
<input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} />
<span>${p.nome}</span>
<span>${pidEscapeHtml(p.nome)}</span>
</label>`
)
.join("");

View File

@ -10,6 +10,22 @@ async function pidEnsureCsrfCookie() {
await fetch(`${PID_API_BASE}/auth/csrf/`, { credentials: "include" });
}
/**
* Escape de texto para montar HTML por template string (`innerHTML`). Vale
* para conteúdo **e** para valor de atributo entre aspas duplas ou simples:
* trata `& < > " '`. Todo dado vindo do servidor, do usuário ou de arquivo
* anexado passa por aqui antes de entrar num `innerHTML` (senão um texto com
* HTML vira código executado no navegador de quem abre a tela: XSS). Nunca
* usar em HTML que já é intencional (texto rico sanitizado no servidor com
* nh3, como a Ajuda e as observações de Acessos Gerais).
*/
const PID_ESCAPE_HTML_MAPA = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
function pidEscapeHtml(texto) {
if (texto === null || texto === undefined) return "";
return String(texto).replace(/[&<>"']/g, (ch) => PID_ESCAPE_HTML_MAPA[ch]);
}
function pidErrorMessageFrom(data) {
if (!data) return "Erro ao processar a solicitação.";
if (typeof data === "string") return data;

View File

@ -115,7 +115,7 @@ document.addEventListener("DOMContentLoaded", async () => {
function popularSelectCategorias(selecionarId) {
modalCategoriaSelect.innerHTML =
'<option value="">Nenhuma</option>' +
categorias.map((c) => `<option value="${c.id}">${c.nome}</option>`).join("");
categorias.map((c) => `<option value="${pidEscapeHtml(c.id)}">${pidEscapeHtml(c.nome)}</option>`).join("");
if (selecionarId) modalCategoriaSelect.value = String(selecionarId);
}
@ -136,7 +136,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const meusDepartamentos = me.departamentos || [];
modalDepartmentSelect.innerHTML = meusDepartamentos
.map((d) => `<option value="${d.id}">${d.nome}</option>`)
.map((d) => `<option value="${pidEscapeHtml(d.id)}">${pidEscapeHtml(d.nome)}</option>`)
.join("");
function updateDepartmentFieldVisibility() {

View File

@ -32,17 +32,199 @@ const PID_CONC_ROTULO_ALERTA = {
const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" };
// Delegado ao `pidEscapeHtml` (api.js), que já escapa aspas: serve para
// conteúdo e para atributo. `pidConcEscapeAttr` fica como sinônimo.
function pidConcEscape(texto) {
if (texto == null) return "";
const div = document.createElement("div");
div.textContent = String(texto);
return div.innerHTML;
return pidEscapeHtml(texto);
}
function pidConcEscapeAttr(texto) {
return pidEscapeHtml(texto);
}
/** Célula de histórico em uma linha só (reticências + texto completo no
* hover); a contrapartida segue na segunda linha, também cortada. */
function pidConcHistoricoHtml(lanc) {
const texto = lanc.eh_saldo_anterior ? "Saldo anterior" : lanc.historico || "";
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(texto);
const contrapartida = lanc.contrapartida
? `<span class="conc-historico__contrapartida" data-conc-tooltip="${pidConcEscapeAttr(lanc.contrapartida)}">${pidConcEscape(lanc.contrapartida)}</span>`
: "";
return `<td class="conc-historico"><span class="conc-historico__texto" data-conc-tooltip="${pidConcEscapeAttr(texto)}">${historico}</span>${contrapartida}</td>`;
}
// Tooltip do texto cortado com reticências (`[data-conc-tooltip]`), no
// visual do Portal em vez do `title` nativo (pedido do usuário; mesmo
// visual de `.dc-hover-tooltip__texto` do Relatório Contábil). Um único
// elemento `position: fixed` no body (as tabelas cortam `absolute`),
// delegado no document para valer nas linhas recriadas a cada re-render.
// Só aparece quando o texto está de fato cortado, salvo com
// `data-conc-tooltip-sempre` (rótulo de botão só com ícone).
let pidConcTooltipEl = null;
function pidConcMostraTooltip(evento) {
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
if (!alvo) return;
if (!("concTooltipSempre" in alvo.dataset) && alvo.scrollWidth <= alvo.clientWidth) return;
if (!pidConcTooltipEl) {
pidConcTooltipEl = document.createElement("div");
pidConcTooltipEl.className = "conc-tooltip";
pidConcTooltipEl.setAttribute("role", "tooltip");
document.body.appendChild(pidConcTooltipEl);
}
pidConcTooltipEl.textContent = alvo.dataset.concTooltip;
pidConcTooltipEl.hidden = false;
const margem = 8;
const rect = alvo.getBoundingClientRect();
const largura = pidConcTooltipEl.offsetWidth;
const altura = pidConcTooltipEl.offsetHeight;
const esquerda = Math.max(margem, Math.min(rect.left, window.innerWidth - largura - margem));
let topo = rect.top - altura - margem;
if (topo < margem) topo = rect.bottom + margem;
pidConcTooltipEl.style.left = `${esquerda}px`;
pidConcTooltipEl.style.top = `${topo}px`;
}
function pidConcEscondeTooltip(evento) {
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
if (!alvo || !pidConcTooltipEl || alvo.contains(evento.relatedTarget)) return;
pidConcTooltipEl.hidden = true;
}
document.addEventListener("mouseover", pidConcMostraTooltip);
document.addEventListener("mouseout", pidConcEscondeTooltip);
window.addEventListener("scroll", () => { if (pidConcTooltipEl) pidConcTooltipEl.hidden = true; }, true);
function pidConcMoeda(valor) {
return PID_CONC_MOEDA.format(Number(valor) || 0);
}
const PID_CONC_ICONE_INFO =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-5M12 8h.01" stroke-linecap="round"/></svg>';
// ---- Histórico de vinculações da conta (botão "i" do razão) ----
// Pedido do usuário: saber quem vinculou/desvinculou o quê e com qual seq.
// Modal criado sob demanda e injetado no body (padrão de ajuda-aplicacao.js).
let pidConcHistoricoModal = null;
function pidConcHistoricoLancamentoHtml(lanc) {
const seq = lanc.eh_saldo_anterior ? "Saldo anterior" : `Seq. ${pidConcEscape(lanc.sequencia || "—")}`;
return `
<li class="conc-historico-vinc__lanc${lanc.acrescentado ? " is-acrescentado" : ""}">
<span class="conc-historico-vinc__seq">${seq}</span>
<span>${pidConcData(lanc.data)}</span>
<span><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></span>
<span class="conc-historico-vinc__texto">
<span class="conc-historico__texto" data-conc-tooltip="${pidConcEscapeAttr(lanc.historico || "")}">${lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico || "")}</span>
${lanc.contrapartida ? `<span class="conc-historico__contrapartida" data-conc-tooltip="${pidConcEscapeAttr(lanc.contrapartida)}">${pidConcEscape(lanc.contrapartida)}</span>` : ""}
</span>
<span class="conc-num">${pidConcMoeda(lanc.valor)}</span>
${lanc.acrescentado ? '<span class="conc-historico-vinc__novo">acrescentado</span>' : ""}
</li>`;
}
function pidConcHistoricoRegistroHtml(registro) {
const origem = registro.vinculo_automatico ? "automático" : "manual";
return `
<li class="conc-historico-vinc__item conc-historico-vinc__item--${registro.acao}">
<div class="conc-historico-vinc__cabecalho">
<strong>${pidConcEscape(registro.acao_rotulo)}</strong>
<span class="conc-historico-vinc__tipo">${pidConcEscape(registro.tipo_rotulo)} · ${origem}</span>
<span class="conc-historico-vinc__quem">${pidConcEscape(registro.usuario_nome || "—")} · ${pidConcDataHora(registro.criado_em)}</span>
</div>
<ul class="conc-historico-vinc__lancs">${registro.lancamentos.map(pidConcHistoricoLancamentoHtml).join("")}</ul>
</li>`;
}
/** Abre o modal de informação (um só, reaproveitado pelo histórico de
* vinculações e pelo detalhe de um vínculo) e devolve o corpo a preencher. */
function pidConcAbreModalInfo(titulo, subtitulo) {
if (!pidConcHistoricoModal) {
pidConcHistoricoModal = document.createElement("div");
pidConcHistoricoModal.className = "modal-overlay";
pidConcHistoricoModal.hidden = true;
pidConcHistoricoModal.innerHTML = `
<div class="modal-card modal-card--wide">
<h2 class="modal-card__title" data-historico-titulo></h2>
<p class="modal-card__subtitle" data-historico-subtitulo></p>
<div class="conc-historico-vinc" data-historico-corpo></div>
<div class="modal-actions">
<button type="button" class="btn-outline" data-historico-fechar>Fechar</button>
</div>
</div>`;
document.body.appendChild(pidConcHistoricoModal);
const fechar = () => { pidConcHistoricoModal.hidden = true; };
pidConcHistoricoModal.querySelector("[data-historico-fechar]").addEventListener("click", fechar);
pidConcHistoricoModal.addEventListener("click", (evento) => {
if (evento.target === pidConcHistoricoModal) fechar();
});
}
pidConcHistoricoModal.querySelector("[data-historico-titulo]").textContent = titulo;
pidConcHistoricoModal.querySelector("[data-historico-subtitulo]").textContent = subtitulo;
pidConcHistoricoModal.hidden = false;
return pidConcHistoricoModal.querySelector("[data-historico-corpo]");
}
// ---- Detalhe de um vínculo (botão "i" na linha vinculada) ----
// Pedido do usuário: ver o que o vínculo automático considerou sem caçar as
// outras linhas no razão (podem estar longe). Tudo já vem no payload da conta.
const PID_CONC_CRITERIO_VINCULO = {
exato: "Um crédito e um débito de mesmo valor. Havendo mais de um candidato, fica o de data mais próxima (e, no empate, o pagamento feito depois do título).",
combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima).",
sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível.",
saldo_zerado: "Trecho do razão em que o saldo da conta voltou a zero: o que sobrou nele se compensa por definição, então vira um único vínculo.",
tolerancia: "Débito e crédito (ou dois itens) com uma diferença pequena, dentro da tolerância da conciliação (valor fixo ou percentual do título, o maior). Possível juros/desconto. Critério usado só em importações até 25/09/2026: hoje esse caso fica pendente, como sugestão.",
manual: "Vinculado manualmente.",
parcial: "Pagamento parcial vinculado manualmente: a diferença continua em aberto.",
};
function pidConcAbreDetalheVinculo(conta, vinculoId, numero) {
const vinculo = conta.vinculos.find((v) => v.id === vinculoId);
if (!vinculo) return;
const lancs = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId);
const creditos = lancs.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
const debitos = lancs.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
const diferenca = Math.round((creditos - debitos) * 100) / 100;
const automatico = vinculo.tipo !== "manual" && vinculo.tipo !== "parcial";
let criterio = PID_CONC_CRITERIO_VINCULO[vinculo.tipo] || "";
if (!automatico && vinculo.criado_por_nome) criterio = `${criterio} Por ${vinculo.criado_por_nome}.`;
const resultado = diferenca === 0
? "Fecha sem diferença."
: diferenca > 0
? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos${vinculo.tipo === "parcial" ? " (em aberto)" : ""}.`
: `Débitos ${pidConcMoeda(-diferenca)} maiores que os créditos.`;
const corpo = pidConcAbreModalInfo(`Vínculo #${numero || "?"}`, `${conta.conta_numero} - ${conta.fornecedor}`);
corpo.innerHTML = `
<div class="conc-historico-vinc__item conc-historico-vinc__item--vinculou">
<div class="conc-historico-vinc__cabecalho">
<strong>${pidConcEscape(vinculo.tipo_rotulo)}</strong>
<span class="conc-historico-vinc__tipo">${automatico ? "automático" : "manual"}</span>
</div>
<p class="conc-vinculo-detalhe__criterio">${pidConcEscape(criterio)}</p>
<ul class="conc-historico-vinc__lancs">${lancs.map(pidConcHistoricoLancamentoHtml).join("")}</ul>
<div class="conc-vinculo-detalhe__totais">
<span>Créditos <strong>${pidConcMoeda(creditos)}</strong></span>
<span>Débitos <strong>${pidConcMoeda(debitos)}</strong></span>
<span>${resultado}</span>
</div>
</div>`;
}
async function pidConcAbreHistoricoVinculos(conta) {
const corpo = pidConcAbreModalInfo("Histórico de vinculações", `${conta.conta_numero} - ${conta.fornecedor}`);
corpo.innerHTML = '<p class="conc-historico-vinc__vazio">Carregando…</p>';
try {
const registros = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/historico-vinculos/`);
corpo.innerHTML = registros.length
? `<ul class="conc-historico-vinc__lista">${registros.map(pidConcHistoricoRegistroHtml).join("")}</ul>`
: '<p class="conc-historico-vinc__vazio">Nenhuma vinculação ou desvinculação manual registrada nesta conta.</p>';
} catch (erro) {
corpo.innerHTML = `<p class="modal-error">${pidConcEscape(erro.message)}</p>`;
}
}
/** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */
function pidConcSaldo(valor) {
const numero = Number(valor) || 0;
@ -79,7 +261,7 @@ function pidConcValorAbertoHtml(situacao) {
function pidConcSituacaoHtml(situacao) {
if (!situacao) return '<td class="conc-situacao"></td>';
const sugestao = situacao.sugestao
? `<span class="conc-sugestao"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(situacao.sugestao)}</span>`
? `<span class="conc-sugestao" data-conc-tooltip="${pidConcEscapeAttr(situacao.sugestao)}" data-conc-relacionados="${(situacao.relacionados || []).join(",")}"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(situacao.sugestao)}</span>`
: "";
return `<td class="conc-situacao conc-situacao--${situacao.categoria}">${pidConcEscape(situacao.descricao)}${sugestao}</td>`;
}
@ -499,9 +681,10 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
function contasFiltradas() {
/** A conta aparece com o filtro atual? Usado pela lista inteira e depois
* de cada ajuste numa conta (`aplicarRespostaConta`). */
function contaPassaFiltro(c) {
const termo = contasBusca.value.trim().toLowerCase();
let lista = detalhe.contas.filter((c) => {
// Validadas ficam fora por padrão (pedido do usuário): a lista mostra
// o que ainda falta revisar. O chip "Validados" as mostra sempre.
if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false;
@ -516,7 +699,10 @@ document.addEventListener("DOMContentLoaded", async () => {
return false;
}
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
});
}
function contasFiltradas() {
let lista = detalhe.contas.filter(contaPassaFiltro);
if (sortKey) {
const extrator = sortValor[sortKey];
lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir);
@ -626,19 +812,40 @@ document.addEventListener("DOMContentLoaded", async () => {
const estado = estadoDe(resposta.conta.id);
estado.selecionados.clear();
renderCards();
substituirConta(resposta.conta);
// Conta que deixou de caber no filtro (validada com "Mostrar validadas"
// desmarcado, desmarcada no chip "Validados", mudou de situação com um
// chip de situação ativo) sai da lista na hora. Antes ela só era
// redesenhada no lugar e saía no próximo render da lista inteira
// (pedido do usuário: "tem um atraso, às vezes preciso atualizar").
if (contaPassaFiltro(resposta.conta)) substituirConta(resposta.conta);
else removerConta(resposta.conta.id);
}
/** Tira as linhas da conta da lista com um fade curto. */
function removerConta(contaId) {
const linhas = contasBody.querySelectorAll(`tr[data-conta-id="${contaId}"], tr[data-conta-detalhe-id="${contaId}"]`);
linhas.forEach((tr) => tr.classList.add("is-saindo"));
window.setTimeout(() => {
linhas.forEach((tr) => tr.remove());
if (!contasBody.querySelector("tr[data-conta-id]")) {
contasVazia.hidden = false;
contasVazia.textContent =
!mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim()
? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los."
: "Nenhum fornecedor para este filtro.";
}
}, 160);
}
function linhaLancamentoHtml(lanc, comCheckbox, selecionado) {
const situacao = lanc.situacao;
const valorAberto = pidConcValorAbertoHtml(situacao);
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
return `
<tr class="${selecionado ? "is-selecionado" : ""}" data-lanc-id="${lanc.id}">
${comCheckbox ? `<td class="conc-subtabela__check"><input type="checkbox" data-lanc-check="${lanc.id}" ${selecionado ? "checked" : ""} aria-label="Selecionar lançamento" /></td>` : ""}
<td>${pidConcData(lanc.data)}</td>
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
<td class="conc-historico">${historico}${lanc.contrapartida ? `<span class="conc-historico__contrapartida">${pidConcEscape(lanc.contrapartida)}</span>` : ""}</td>
${pidConcHistoricoHtml(lanc)}
<td><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></td>
<td class="conc-num conc-valor">${pidConcMoeda(lanc.valor)}</td>
${comCheckbox ? `<td class="conc-num conc-valor">${valorAberto}</td>${pidConcSituacaoHtml(situacao)}` : ""}
@ -688,6 +895,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="conc-detail__acoes">
<span class="conc-selecao-resumo" data-selecao-resumo></span>
<button type="button" class="btn-solid conc-btn-sm" data-vincular disabled>Vincular selecionados</button>
<button type="button" class="conc-info-btn" data-historico-vinculos aria-label="Histórico de vinculações" data-conc-tooltip="Histórico de vinculações" data-conc-tooltip-sempre>${PID_CONC_ICONE_INFO}</button>
</div>
</div>`;
if (razao.length) {
@ -704,12 +912,10 @@ document.addEventListener("DOMContentLoaded", async () => {
painel.appendChild(secaoPend);
function linhaRazaoHtml(lanc) {
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
const contrapartida = lanc.contrapartida ? `<span class="conc-historico__contrapartida">${pidConcEscape(lanc.contrapartida)}</span>` : "";
const comum = `
<td>${pidConcData(lanc.data)}</td>
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
<td class="conc-historico">${historico}${contrapartida}</td>
${pidConcHistoricoHtml(lanc)}
<td><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></td>
<td class="conc-num conc-valor">${pidConcMoeda(lanc.valor)}</td>
<td class="conc-num conc-valor conc-valor--saldo">${lanc.saldo_apos === null ? "—" : pidConcSaldo(lanc.saldo_apos)}</td>`;
@ -735,6 +941,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<span class="conc-vinculado-tag">Vinculado #${numeroVinculo.get(lanc.vinculo_id) || "?"}</span>
<span class="conc-vinculado__tipo">${pidConcEscape(vinculo ? vinculo.tipo_rotulo : "")} · ${origem}</span>
<button type="button" class="conc-link-btn" data-desfazer-vinculo="${lanc.vinculo_id}">Desfazer</button>
<button type="button" class="conc-info-btn conc-info-btn--linha" data-ver-vinculo="${lanc.vinculo_id}" aria-label="Ver o vínculo" data-conc-tooltip="Ver o que este vínculo considerou" data-conc-tooltip-sempre>${PID_CONC_ICONE_INFO}</button>
</span>
</td>
</tr>`;
@ -765,6 +972,43 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo")));
tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-grupo")));
});
// Sugestão: ao passar o mouse, destaca a própria linha e as do outro
// lado da sugestão (pedido do usuário, como o destaque do vínculo). Id
// negativo é o saldo de um vínculo parcial: destaca as linhas dele.
secaoPend.querySelectorAll("[data-conc-relacionados]").forEach((sugestaoEl) => {
const linha = sugestaoEl.closest("tr");
const ids = sugestaoEl.dataset.concRelacionados.split(",").filter(Boolean).map(Number);
const grupo = () => [
linha,
...ids.flatMap((id) =>
Array.from(secaoPend.querySelectorAll(id < 0 ? `tr[data-vinculo-id="${-id}"]` : `tr[data-lanc-id="${id}"]`))
),
];
sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo")));
sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo")));
// Duplo clique seleciona a linha e as relacionadas; se já estavam todas
// selecionadas, desmarca (pedido do usuário). Só as não vinculadas
// (com checkbox): o saldo de vínculo parcial fica só no destaque.
sugestaoEl.addEventListener("dblclick", (evento) => {
evento.stopPropagation();
if (window.getSelection) window.getSelection().removeAllRanges();
const checks = grupo()
.map((tr) => tr.querySelector("[data-lanc-check]"))
.filter(Boolean);
const marcar = !checks.every((check) => check.checked);
checks.forEach((check) => {
if (check.checked === marcar) return;
check.checked = marcar;
check.dispatchEvent(new Event("change"));
});
});
});
secaoPend.querySelector("[data-historico-vinculos]").addEventListener("click", (evento) => {
evento.stopPropagation();
pidConcAbreHistoricoVinculos(conta);
});
// "Ocultar conciliados": desmarcado por padrão (pedido do usuário); a
// escolha fica no estado da conta e sobrevive ao re-render após um vínculo.
const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]");
@ -777,6 +1021,14 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
secaoPend.querySelectorAll("[data-ver-vinculo]").forEach((btn) => {
btn.addEventListener("click", (evento) => {
evento.stopPropagation();
const vinculoId = Number(btn.dataset.verVinculo);
pidConcAbreDetalheVinculo(conta, vinculoId, numeroVinculo.get(vinculoId));
});
});
secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => {
btn.addEventListener("click", (evento) => {
evento.stopPropagation();
@ -832,6 +1084,9 @@ document.addEventListener("DOMContentLoaded", async () => {
secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id])").forEach((tr) => {
tr.addEventListener("click", (evento) => {
if (evento.target.closest("[data-lanc-check]")) return;
// A sugestão é reservada ao duplo clique (seleciona o grupo); o
// clique simples nela marcaria/desmarcaria a linha duas vezes antes.
if (evento.target.closest("[data-conc-relacionados]")) return;
if (String(window.getSelection ? window.getSelection() : "").trim()) return;
const check = tr.querySelector("[data-lanc-check]");
check.checked = !check.checked;

View File

@ -178,10 +178,11 @@ function pidDcFormatDataHora(iso) {
});
}
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas: esta função
// é usada dentro de atributos (`aria-label`, `value`), e a versão antiga por
// `textContent` deixava `"` passar (XSS por texto de observação).
function pidDcEscapeHtml(texto) {
const div = document.createElement("div");
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
return pidEscapeHtml(texto);
}
// Barra de progresso de "Nova Análise"/"Reprocessar" — o processamento roda
@ -616,7 +617,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<td>${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}</td>
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_achados_pendentes}</td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${pidDcEscapeHtml(PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status)}</span></td>
<td>${pidDcEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidDcFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions">
@ -685,7 +686,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
function dcEscapeAttr(value) {
return (value === null || value === undefined ? "" : String(value)).replace(/"/g, "&quot;");
return pidEscapeHtml(value);
}
function dcCriarFiltroColuna(campo) {
@ -1310,8 +1311,8 @@ document.addEventListener("DOMContentLoaded", async () => {
card.innerHTML = `
<div class="dc-achado-card__main">
<div class="dc-achado-card__titulo">
<span class="dc-badge dc-badge--${achado.severidade}">${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade}</span>
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
<span class="dc-badge dc-badge--${achado.severidade}">${pidDcEscapeHtml(PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade)}</span>
<span class="dc-badge dc-badge--${achado.status}">${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)}</span>
${pidDcEscapeHtml(achado.titulo)}
</div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
@ -2813,7 +2814,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="dc-achado-card dc-achado-card--${achado.severidade} dc-variacao${achado.status === "tratado" ? " dc-variacao--validada" : ""}${linhaId != null && dcVariacaoFoco === linhaId ? " dc-variacao--foco" : ""}" data-dc-variacao-linha="${linhaId ?? ""}">
<div class="dc-achado-card__main">
<div class="dc-achado-card__titulo">
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
<span class="dc-badge dc-badge--${achado.status}">${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)}</span>
${pidDcEscapeHtml(achado.titulo)}
</div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
@ -3251,7 +3252,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<button
type="button"
class="icon-btn dc-dash-toggle-btn"
data-dc-dash-indicador-toggle="${chave}"
data-dc-dash-indicador-toggle="${pidDcEscapeHtml(chave)}"
aria-label="${oculto ? "Mostrar" : "Ocultar"} no relatório"
title="${oculto ? "Mostrar no relatório" : "Ocultar do relatório"}"
${concluida ? "disabled" : ""}
@ -3775,7 +3776,7 @@ document.addEventListener("DOMContentLoaded", async () => {
: `<input
type="checkbox"
class="dc-ind-hub-item__ativo"
data-dc-hub-toggle="${definicao.chave}"
data-dc-hub-toggle="${pidDcEscapeHtml(definicao.chave)}"
${ativo ? "checked" : ""}
${concluida ? "disabled" : ""}
aria-label="Ativo nesta apuração"

View File

@ -69,8 +69,8 @@ function pidCriarSeletorDuplo(config) {
(c) => `
<tr>
<td><input type="checkbox" data-dual-select="${c.id}" ${state.selected.has(c.id) ? "checked" : ""} /></td>
<td>${c.nome}</td>
<td>${secundariaTexto(c)}</td>
<td>${pidEscapeHtml(c.nome)}</td>
<td>${pidEscapeHtml(secundariaTexto(c))}</td>
</tr>`
)
.join("")

View File

@ -120,10 +120,10 @@ document.addEventListener("DOMContentLoaded", async () => {
const cardsHtml = favApps
.map(
(app) => `
<article class="app-card" data-fav-id="${app.id}" role="button" tabindex="0" draggable="true">
<article class="app-card" data-fav-id="${pidEscapeHtml(app.id)}" role="button" tabindex="0" draggable="true">
${pidIconHtmlFor(app)}
<span class="app-card__label">${app.label}</span>
<button type="button" class="app-card__remove" data-fav-remove="${app.id}" aria-label="Remover dos favoritos" draggable="false">
<span class="app-card__label">${pidEscapeHtml(app.label)}</span>
<button type="button" class="app-card__remove" data-fav-remove="${pidEscapeHtml(app.id)}" aria-label="Remover dos favoritos" draggable="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8 0 1 12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1l1-12" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</article>`

View File

@ -784,11 +784,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const statusTexto = imp.status === "concluida" ? "Concluída" : "Em revisão";
tr.innerHTML = `
<td class="ips-select-col"><input type="checkbox" class="ips-select-row" data-select-id="${imp.id}" aria-label="Selecionar esta importação" ${selectedIds.has(imp.id) ? "checked" : ""} /></td>
<td>${imp.codigo_empresa || "—"}</td>
<td>${imp.nome_operadora}</td>
<td>${tiposLabel(imp.tipos_lancamento)}</td>
<td>${escapeHtml(imp.codigo_empresa || "—")}</td>
<td>${escapeHtml(imp.nome_operadora)}</td>
<td>${escapeHtml(tiposLabel(imp.tipos_lancamento))}</td>
<td><span class="status-pill ${statusClasse}">${statusTexto}</span></td>
<td>${imp.criado_por_nome || "—"}</td>
<td>${escapeHtml(imp.criado_por_nome || "—")}</td>
<td>${criadoEm}</td>
<td class="ips-table-actions">
<button type="button" class="icon-btn ips-remove-linha" data-remover-importacao="${imp.id}" aria-label="Excluir importação" title="Excluir importação">
@ -1457,7 +1457,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<p class="ips-regra-row__nome">${escapeHtml(r.label)}</p>
</div>
<div class="ips-regra-row__actions">
<button type="button" class="btn-outline" data-regra-empresa-selecionar="${r.key}">Selecionar</button>
<button type="button" class="btn-outline" data-regra-empresa-selecionar="${escapeAttr(r.key)}">Selecionar</button>
</div>
</div>`
)
@ -1829,27 +1829,24 @@ document.addEventListener("DOMContentLoaded", async () => {
tabsEl.innerHTML = tabKeys
.map((key) => {
const label = key === "auditoria" ? "Auditoria" : key === "alteracoes" ? "Alterações" : PID_IPS_TIPO_LABELS[key] || key;
return `<button type="button" class="pa-tab${key === activeTab ? " is-active" : ""}" data-ips-tab="${key}">${label}</button>`;
return `<button type="button" class="pa-tab${key === activeTab ? " is-active" : ""}" data-ips-tab="${escapeAttr(key)}">${escapeHtml(label)}</button>`;
})
.join("");
renderPanels();
}
// As duas delegam ao `pidEscapeHtml` (api.js), que trata `& < > " '`. O
// comentário antigo dizia que só o texto livre das regras de custeio
// precisava de escape completo e que o resto vinha de "fontes fixas", mas
// nome, CPF e detalhe da auditoria vêm do arquivo anexado da operadora:
// todo dado entra escapado (revisão de interface de 2026-09-25, XSS).
function escapeAttr(value) {
return (value === null || value === undefined ? "" : String(value)).replace(/"/g, "&quot;");
return pidEscapeHtml(value);
}
// Igual a escapeAttr, mas também escapa "<"/">"/"&" — usado pra inserir via
// innerHTML texto livre digitado pelo colaborador (nome/observações de uma
// regra de custeio salva), que ao contrário do resto dos campos desta tela
// (nomes de operadora/arquivo, vindos de fontes fixas) pode conter
// qualquer caractere.
function escapeHtml(value) {
return (value === null || value === undefined ? "" : String(value)).replace(
/[&<>"]/g,
(ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]
);
return pidEscapeHtml(value);
}
function resizeHandleHtml(campo, target) {
@ -2107,7 +2104,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>
${concluida ? "" : `
<div class="ips-add-row">
<button type="button" class="btn-outline" data-ips-add-linha="${tipo}">
<button type="button" class="btn-outline" data-ips-add-linha="${escapeAttr(tipo)}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>
Adicionar linha
</button>
@ -2141,7 +2138,7 @@ document.addEventListener("DOMContentLoaded", async () => {
let acaoHtml = `<span class="ips-empty-cell">—</span>`;
if (item.resolvida) {
acaoHtml = `<span class="ips-auditoria-resolvido" title="Vinculado a: ${escapeAttr(item.linha_vinculada_nome || "")}">Resolvido${
item.linha_vinculada_nome ? ` — ${item.linha_vinculada_nome}` : ""
item.linha_vinculada_nome ? ` — ${escapeHtml(item.linha_vinculada_nome)}` : ""
}</span>`;
} else if (importacaoAtual.status !== "concluida" && PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)) {
acaoHtml = `<button type="button" class="btn-outline ips-vincular-btn" data-vincular-auditoria="${item.id}">Vincular pessoa</button>`;
@ -2149,13 +2146,13 @@ document.addEventListener("DOMContentLoaded", async () => {
return `
<div class="ips-grid-row" role="row">
<div class="ips-grid-cell ips-table-actions" role="cell">${acaoHtml}</div>
<div class="ips-grid-cell" role="cell">${item.nome || "—"}</div>
<div class="ips-grid-cell" role="cell">${item.cpf || "—"}</div>
<div class="ips-grid-cell" role="cell">${escapeHtml(item.nome || "—")}</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">${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" role="cell"><span class="ips-auditoria-motivo">${PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo}</span></div>
<div class="ips-grid-cell" role="cell">${item.detalhe || ""}</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>`;
})
.join("");
@ -2213,8 +2210,8 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr>
<td class="ips-table-actions">${acaoHtml}</td>
<td>${new Date(alt.criado_em).toLocaleString("pt-BR")}</td>
<td><span class="ips-alteracao-tipo ips-alteracao-tipo--${alt.tipo}">${PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo}</span></td>
<td>${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"}</td>
<td><span class="ips-alteracao-tipo ips-alteracao-tipo--${escapeAttr(alt.tipo)}">${escapeHtml(PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo)}</span></td>
<td>${escapeHtml(PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—")}</td>
<td>${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"}</td>
<td>${detalheHtml}</td>
<td>${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"}</td>
@ -2247,7 +2244,7 @@ document.addEventListener("DOMContentLoaded", async () => {
.map((key) => {
const html =
key === "auditoria" ? panelHtmlAuditoria() : key === "alteracoes" ? panelHtmlAlteracoes() : panelHtmlParaTipo(key);
return `<div class="pa-tab-panel" data-ips-tab-panel="${key}" ${key === activeTab ? "" : "hidden"}>${html}</div>`;
return `<div class="pa-tab-panel" data-ips-tab-panel="${escapeAttr(key)}" ${key === activeTab ? "" : "hidden"}>${html}</div>`;
})
.join("");
wireColumnResize(panelsEl);
@ -2290,8 +2287,8 @@ document.addEventListener("DOMContentLoaded", async () => {
<label class="checklist-item">
<input type="radio" name="ips-vincular-linha" value="${l.id}" ${vincularLinhaSelecionada === l.id ? "checked" : ""} />
<span class="checklist-item__info">
<span class="checklist-item__nome">${nomePrincipal}</span>
<span class="checklist-item__departamento">${contexto}</span>
<span class="checklist-item__nome">${escapeHtml(nomePrincipal)}</span>
<span class="checklist-item__departamento">${escapeHtml(contexto)}</span>
</span>
</label>`;
})

View File

@ -82,16 +82,14 @@ function pidIndFormatData(iso) {
return new Date(iso).toLocaleDateString("pt-BR");
}
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão
// antiga por `textContent` não escapava, e a função é usada em atributos).
function pidIndEscapeHtml(texto) {
const div = document.createElement("div");
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
return pidEscapeHtml(texto);
}
function pidIndEscapeAttr(texto) {
return String(texto == null ? "" : texto)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
return pidEscapeHtml(texto);
}
// "Cada gerente representa um grupo" (CLAUDE.md): agrupa os colaboradores da
@ -417,7 +415,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.innerHTML = `
<td>${pidIndFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_colaboradores}</td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${pidIndEscapeHtml(PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status)}</span></td>
<td>${pidIndEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidIndFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions">
@ -1793,11 +1791,11 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr>
<td>${pidIndEscapeHtml(criterio.nome)}</td>
<td>${pidIndEscapeHtml(criterio.departamento_nome)}</td>
<td>${PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo}</td>
<td>${pidIndEscapeHtml(PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo)}</td>
<td>${pidIndFormatPercentual(criterio.peso)}</td>
<td>${PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo}</td>
<td>${pidIndEscapeHtml(PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo)}</td>
<td>${criterio.papel_aplicavel ? PID_IND_TIPO_LABELS[criterio.papel_aplicavel] : "Qualquer"}</td>
<td>${PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico}</td>
<td>${pidIndEscapeHtml(PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico)}</td>
<td><span class="status-pill ${criterio.ativo ? "status-pill--ativo" : "status-pill--inativo"}">${criterio.ativo ? "Ativo" : "Inativo"}</span></td>
<td class="pa-row-actions">
<button type="button" class="icon-btn" data-ind-criterio-toggle="${criterio.id}" title="${criterio.ativo ? "Desativar" : "Ativar"}">
@ -1823,7 +1821,7 @@ document.addEventListener("DOMContentLoaded", async () => {
(percentual) => `
<tr>
<td>${pidIndEscapeHtml(percentual.departamento_nome)}</td>
<td>${percentual.tipo_label}</td>
<td>${pidIndEscapeHtml(percentual.tipo_label)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_individual)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_grupo)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_departamento)}</td>

View File

@ -80,7 +80,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
function cardHtml(link, index) {
const iconHtml = link.icone ? `<img class="lf-card__icon" src="${link.icone}" alt="" />` : PID_LINK_DEFAULT_ICON;
const iconHtml = link.icone ? `<img class="lf-card__icon" src="${pidEscapeHtml(link.icone)}" alt="" />` : PID_LINK_DEFAULT_ICON;
const isFavorite = favoritos.has(link.id);
const favoriteBtn = `
@ -110,9 +110,9 @@ document.addEventListener("DOMContentLoaded", async () => {
${favoriteBtn}
${adminHtml}
</div>
<a class="lf-card__link" href="${link.url}" target="_blank" rel="noopener noreferrer" draggable="false">
<a class="lf-card__link" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" draggable="false">
${iconHtml}
<span class="lf-card__label">${link.nome}</span>
<span class="lf-card__label">${pidEscapeHtml(link.nome)}</span>
</a>
</article>`;
}

View File

@ -21,11 +21,10 @@ function pidNcfFormatDateTime(iso) {
return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão
// antiga por `textContent` não escapava, e a função é usada em `value="..."`).
function pidNcfEscapeHtml(texto) {
if (texto == null) return "";
const div = document.createElement("div");
div.textContent = String(texto);
return div.innerHTML;
return pidEscapeHtml(texto);
}
function pidNcfAtualizarContador(el, quantidade) {
@ -392,7 +391,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.appendChild(tdBtn);
tr.insertAdjacentHTML(
"beforeend",
`<td>${oc.codigo}</td>
`<td>${pidNcfEscapeHtml(oc.codigo)}</td>
<td>${pidNcfFormatDate(oc.data_emissao)}</td>
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.emissor_relato) || "—"}</td>
@ -471,7 +470,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.appendChild(tdBtn);
tr.insertAdjacentHTML(
"beforeend",
`<td>${oc.codigo}</td>
`<td>${pidNcfEscapeHtml(oc.codigo)}</td>
<td>${pidNcfFormatDate(oc.data_emissao)}</td>
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.responsavel_analise) || "—"}</td>
@ -525,7 +524,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const detalhe = await pidApiRequest(`/nao-conformidades/ocorrencias/${item.id}/`);
const acoesHtml = (detalhe.acoes || []).length
? `<h4>Ações vinculadas</h4><ul class="ncf-detail-lista">${detalhe.acoes
.map((a) => `<li>${a.ocorrencia_codigo}/${a.codigo} — ${pidNcfEscapeHtml((a.acao_texto || "").slice(0, 80))}</li>`)
.map((a) => `<li>${pidNcfEscapeHtml(a.ocorrencia_codigo)}/${pidNcfEscapeHtml(a.codigo)} — ${pidNcfEscapeHtml((a.acao_texto || "").slice(0, 80))}</li>`)
.join("")}</ul>`
: "";
extra.innerHTML = `
@ -642,7 +641,7 @@ document.addEventListener("DOMContentLoaded", async () => {
: "";
tr.insertAdjacentHTML(
"beforeend",
`<td>${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}</td>
`<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_codigo)}/${pidNcfEscapeHtml(acaoItem.codigo)}</td>
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"}</td>

View File

@ -92,15 +92,18 @@ function pidSortEventsByDate(a, b) {
return dateCompare !== 0 ? dateCompare : (a.horario || "").localeCompare(b.horario || "");
}
/** `date`/`title`/`breadcrumb` já saem daqui escapados (dado da API) — são
* interpolados direto no innerHTML de `render`/`renderHistory`, sem escapar de
* novo (o breadcrumb tem `&gt;` fixo de propósito). */
function pidMapEventToNotification(ev) {
return {
id: `event-${ev.id}`,
type: "event",
date: pidFormatNotifDate(ev.data),
title: ev.titulo.toUpperCase(),
date: pidEscapeHtml(pidFormatNotifDate(ev.data)),
title: pidEscapeHtml(ev.titulo.toUpperCase()),
breadcrumb: ev.sou_dono
? "Menu &gt; Calendário Individual"
: `Menu &gt; Calendário Individual &gt; Compartilhado por ${ev.dono_nome}`,
: `Menu &gt; Calendário Individual &gt; Compartilhado por ${pidEscapeHtml(ev.dono_nome)}`,
};
}

View File

@ -204,7 +204,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr>
<td>${p.codigo}</td>
<td>
<a href="#" class="pa-name-link" data-pa-edit="${p.codigo}">${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>' : ""}
</td>
<td>
@ -229,9 +229,9 @@ document.addEventListener("DOMContentLoaded", async () => {
moduleListEl.innerHTML = PID_MODULES.map((m, idx) => {
const enabled = draftProfile.permissions[m.key].enabled;
return `
<button type="button" class="pa-module-item ${m.key === activeModuleKey ? "is-active" : ""}" data-pa-module="${m.key}">
<button type="button" class="pa-module-item ${m.key === activeModuleKey ? "is-active" : ""}" data-pa-module="${pidEscapeHtml(m.key)}">
<span class="pa-module-item__index">${idx + 1}</span>
<span class="pa-module-item__label">${m.label}</span>
<span class="pa-module-item__label">${pidEscapeHtml(m.label)}</span>
<svg class="pa-module-item__check" ${enabled ? "" : "hidden"} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`;
}).join("");
@ -241,8 +241,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const match = !term || tool.label.toLowerCase().includes(term);
return `
<label class="perm-node perm-node--leaf ${match ? "" : "is-hidden"}">
<input type="checkbox" data-perm-app="${tool.key}" ${perm.apps[tool.key] ? "checked" : ""} />
<span>${tool.label}</span>
<input type="checkbox" data-perm-app="${pidEscapeHtml(tool.key)}" ${perm.apps[tool.key] ? "checked" : ""} />
<span>${pidEscapeHtml(tool.label)}</span>
</label>`;
}
@ -259,12 +259,12 @@ document.addEventListener("DOMContentLoaded", async () => {
return `
<div class="perm-node perm-node--group ${anyMatch ? "" : "is-hidden"}">
<button type="button" class="perm-expand ${subgroupExpanded ? "is-open" : ""}" data-perm-subgroup-expand="${subgroupKey}" aria-label="Expandir">
<button type="button" class="perm-expand ${subgroupExpanded ? "is-open" : ""}" data-perm-subgroup-expand="${pidEscapeHtml(subgroupKey)}" aria-label="Expandir">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<label>
<input type="checkbox" data-perm-group="${entry.key}" ${groupChecked ? "checked" : ""} />
<span>${entry.label}</span>
<input type="checkbox" data-perm-group="${pidEscapeHtml(entry.key)}" ${groupChecked ? "checked" : ""} />
<span>${pidEscapeHtml(entry.label)}</span>
</label>
</div>
<div class="perm-children perm-children--nested" data-perm-children ${subgroupExpanded ? "" : "hidden"}>
@ -295,7 +295,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
<label>
<input type="checkbox" data-perm-role="module" ${perm.enabled ? "checked" : ""} />
<span>${moduleLabel}</span>
<span>${pidEscapeHtml(moduleLabel)}</span>
</label>
</div>
${
@ -339,8 +339,8 @@ document.addEventListener("DOMContentLoaded", async () => {
(u) => `
<tr>
<td><input type="checkbox" data-pa-users-select="${u.id}" ${state.selected.has(u.id) ? "checked" : ""} /></td>
<td>${u.nome}</td>
<td>${u.email || "—"}</td>
<td>${pidEscapeHtml(u.nome)}</td>
<td>${pidEscapeHtml(u.email || "—")}</td>
</tr>`
)
.join("")
@ -410,9 +410,9 @@ document.addEventListener("DOMContentLoaded", async () => {
? items
.map(
(a) => `
<button type="button" class="pa-app-result-item" data-pa-app-module="${a.moduleKey}" data-pa-app-key="${a.key}">
<span class="pa-app-result-item__label">${a.label}</span>
<span class="pa-app-result-item__module">${a.moduleLabel}</span>
<button type="button" class="pa-app-result-item" data-pa-app-module="${pidEscapeHtml(a.moduleKey)}" data-pa-app-key="${pidEscapeHtml(a.key)}">
<span class="pa-app-result-item__label">${pidEscapeHtml(a.label)}</span>
<span class="pa-app-result-item__module">${pidEscapeHtml(a.moduleLabel)}</span>
</button>`
)
.join("")
@ -428,7 +428,7 @@ document.addEventListener("DOMContentLoaded", async () => {
appManageThead.innerHTML = `
<tr>
<th>Perfil</th>
${tools.map((tool) => `<th>${tool.label.split(" (")[0]}</th>`).join("")}
${tools.map((tool) => `<th>${pidEscapeHtml(tool.label.split(" (")[0])}</th>`).join("")}
</tr>`;
const rows = profiles
@ -442,11 +442,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const perm = p.permissions[entry.moduleKey];
return `
<tr>
<td>${p.nome} ${p.ativo ? "" : '<span class="status-pill status-pill--inativo">Inativo</span>'}</td>
<td>${pidEscapeHtml(p.nome)} ${p.ativo ? "" : '<span class="status-pill status-pill--inativo">Inativo</span>'}</td>
${tools
.map(
(tool) => `
<td><input type="checkbox" data-pa-app-perfil="${p.codigo}" data-pa-app-key="${tool.key}" ${
<td><input type="checkbox" data-pa-app-perfil="${p.codigo}" data-pa-app-key="${pidEscapeHtml(tool.key)}" ${
perm.apps[tool.key] ? "checked" : ""
} /></td>`
)

View File

@ -36,7 +36,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const botoes = [`<button type="button" class="ram-lookup-depto-btn${activeDepto === "" ? " is-active" : ""}" data-depto="">Todos</button>`].concat(
departamentos.map(
(d) =>
`<button type="button" class="ram-lookup-depto-btn${activeDepto === d.nome ? " is-active" : ""}" data-depto="${d.nome}">${d.nome}</button>`
`<button type="button" class="ram-lookup-depto-btn${activeDepto === d.nome ? " is-active" : ""}" data-depto="${pidEscapeHtml(d.nome)}">${pidEscapeHtml(d.nome)}</button>`
)
);
deptoFiltrosEl.innerHTML = botoes.join("");
@ -115,9 +115,9 @@ document.addEventListener("DOMContentLoaded", async () => {
.map(
(r) => `
<tr>
<td>${r.nome_exibicao}</td>
<td>${r.departamento_exibicao || "—"}</td>
<td>${r.numero_exibicao || "—"}</td>
<td>${pidEscapeHtml(r.nome_exibicao)}</td>
<td>${pidEscapeHtml(r.departamento_exibicao || "—")}</td>
<td>${pidEscapeHtml(r.numero_exibicao || "—")}</td>
</tr>`
)
.join("")

View File

@ -232,7 +232,7 @@ document.addEventListener("DOMContentLoaded", async () => {
let editingId = null;
function usuarioOptionsHtml() {
return usuariosDisponiveis.map((u) => `<option value="${u.id}">${u.nome}</option>`).join("");
return usuariosDisponiveis.map((u) => `<option value="${pidEscapeHtml(u.id)}">${pidEscapeHtml(u.nome)}</option>`).join("");
}
function rowHtml(ramal) {
@ -241,11 +241,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const rowClass = aniversario ? "ram-row--aniversario" : ausente ? "ram-row--ausente" : "";
const nomeHtml = aniversario
? `${ramal.nome_exibicao} <span class="ram-badge ram-badge--aniversario">🎉 Feliz Aniversário</span>`
: ramal.nome_exibicao;
? `${pidEscapeHtml(ramal.nome_exibicao)} <span class="ram-badge ram-badge--aniversario">🎉 Feliz Aniversário</span>`
: pidEscapeHtml(ramal.nome_exibicao);
const numeroHtml = ramal.numero_exibicao
? ramal.numero_exibicao
? pidEscapeHtml(ramal.numero_exibicao)
: `<span class="ram-badge ram-badge--pendente">Pendente</span>`;
const ausenteBadgeHtml = ausente
@ -270,7 +270,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return `
<tr class="${rowClass}">
<td>${nomeHtml}</td>
<td>${ramal.departamento_exibicao || "—"}</td>
<td>${pidEscapeHtml(ramal.departamento_exibicao || "—")}</td>
<td>${ramalHtml}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`;
@ -317,10 +317,10 @@ document.addEventListener("DOMContentLoaded", async () => {
return `
<tr>
<td>${tel.nome}</td>
<td>${tel.ramal || "—"}</td>
<td>${tel.telefone || "—"}</td>
<td>${tel.observacoes || "—"}</td>
<td>${pidEscapeHtml(tel.nome)}</td>
<td>${pidEscapeHtml(tel.ramal || "—")}</td>
<td>${pidEscapeHtml(tel.telefone || "—")}</td>
<td>${pidEscapeHtml(tel.observacoes || "—")}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`;
}
@ -379,9 +379,9 @@ document.addEventListener("DOMContentLoaded", async () => {
return `
<tr>
<td>${fun.comando}</td>
<td>${fun.funcao}</td>
<td>${fun.resumo || "—"}</td>
<td>${pidEscapeHtml(fun.comando)}</td>
<td>${pidEscapeHtml(fun.funcao)}</td>
<td>${pidEscapeHtml(fun.resumo || "—")}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`;
}
@ -480,7 +480,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
function fillAusenciaViewFields(ausencia, ramal) {
ausViewUsuario.innerHTML = `<option>${ramal.nome_exibicao} - ${ramal.departamento_exibicao || "—"} / Ramal: ${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 || "";
ausViewHoraInicio.value = ausencia.hora_inicio ? ausencia.hora_inicio.slice(0, 5) : "";
ausViewDataFim.value = ausencia.data_fim || "";

View File

@ -120,7 +120,7 @@ document.addEventListener("DOMContentLoaded", async () => {
// Célula vazia fica destacada (itálico, cor apagada) — ordenar a coluna
// agrupa quem ainda não tem aquele código/ramal cadastrado.
function celulaCodigo(valor) {
return valor ? valor : '<span class="ua-campo-vazio">—</span>';
return valor ? pidEscapeHtml(valor) : '<span class="ua-campo-vazio">—</span>';
}
function renderList() {
@ -143,14 +143,14 @@ document.addEventListener("DOMContentLoaded", async () => {
.map(
(u) => `
<tr>
<td>${u.username}</td>
<td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${u.nome}</a></td>
<td>${pidEscapeHtml(u.username)}</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>${celulaCodigo(u.codigo_questor)}</td>
<td>${celulaCodigo(u.codigo_tareffa)}</td>
<td>${celulaCodigo(u.codigo_contabit)}</td>
<td>${celulaCodigo(u.ramal)}</td>
<td>${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>
<div class="pa-row-actions">
@ -180,7 +180,7 @@ document.addEventListener("DOMContentLoaded", async () => {
(p) => `
<label class="checklist-item">
<input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} />
<span>${p.nome}</span>
<span>${pidEscapeHtml(p.nome)}</span>
</label>`
)
.join("");
@ -211,7 +211,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="ua-departamento-item">
<label class="checklist-item">
<input type="checkbox" value="${d.id}" ${selectedIds.includes(d.id) ? "checked" : ""} />
<span>${d.nome}</span>
<span>${pidEscapeHtml(d.nome)}</span>
</label>
<div class="ua-departamento-item__actions">
<button type="button" class="ua-departamento-action" data-dep-edit="${d.id}" aria-label="Renomear departamento">

View File

@ -80,9 +80,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const dateLabel = horario ? `${day}/${month} ${horario}` : `${day}/${month}`;
return `
<div class="widget-agenda-row">
<span class="widget-agenda-row__date">${dateLabel}</span>
<span class="widget-agenda-row__title">${ev.titulo}</span>
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${ev.dono_nome}</span>` : ""}
<span class="widget-agenda-row__date">${pidEscapeHtml(dateLabel)}</span>
<span class="widget-agenda-row__title">${pidEscapeHtml(ev.titulo)}</span>
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${pidEscapeHtml(ev.dono_nome)}</span>` : ""}
</div>`;
})
.join("");
@ -101,12 +101,12 @@ document.addEventListener("DOMContentLoaded", async () => {
${favoritos
.map((link) => {
const iconHtml = link.icone
? `<img class="widget-links-list__icon" src="${link.icone}" alt="" />`
? `<img class="widget-links-list__icon" src="${pidEscapeHtml(link.icone)}" alt="" />`
: PID_WIDGET_LINK_DEFAULT_ICON;
return `
<a class="widget-links-list__row" href="${link.url}" target="_blank" rel="noopener noreferrer">
<a class="widget-links-list__row" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">
${iconHtml}
<span>${link.nome}</span>
<span>${pidEscapeHtml(link.nome)}</span>
</a>`;
})
.join("")}
@ -133,7 +133,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const height = widget.altura || PID_WIDGET_DEFAULT_HEIGHT;
const body = widgetBodyCache[widget.tipo] || "";
return `
<article class="widget-card" data-widget-id="${widget.tipo}" style="width:${width}px;height:${height}px;">
<article class="widget-card" data-widget-id="${widget.tipo}" style="width:${pidEscapeHtml(width)}px;height:${pidEscapeHtml(height)}px;">
<div class="widget-card__header" draggable="true">
<h3>${type.label}</h3>
<button type="button" class="widget-card__remove" data-widget-remove="${widget.tipo}" aria-label="Remover widget" draggable="false">