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 # Configuração local do Claude Code
.claude/settings.local.json .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"). `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`). `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 ## 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` Editar os models ou as views do Relatório Contábil não dispara o `CLAUDE.md`
dele. 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 ## Limitações conhecidas / decisões assumidas
- Calendário De Paula (empresa) não tem mais versão interna — depende - Calendário De Paula (empresa) não tem mais versão interna — depende

View File

@ -10,6 +10,7 @@ from .models import (
ConciliacaoFornecedorConta, ConciliacaoFornecedorConta,
ConciliacaoFornecedorLancamento, ConciliacaoFornecedorLancamento,
ConciliacaoFornecedorVinculo, ConciliacaoFornecedorVinculo,
ConciliacaoFornecedorVinculoHistorico,
ContabilAchado, ContabilAchado,
ContabilApuracao, ContabilApuracao,
ContabilApuracaoReprocessamento, ContabilApuracaoReprocessamento,
@ -334,6 +335,12 @@ class ConciliacaoFornecedorVinculoAdmin(admin.ModelAdmin):
list_filter = ("tipo",) 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) @admin.register(ConciliacaoFornecedorLancamento)
class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin): class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin):
list_display = ("conta", "data", "natureza", "valor", "historico", "vinculo") 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) ### 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. 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. 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. 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. 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`. 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. **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. 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. 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). **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 | | Categoria | Significado (fato) | Alerta |
|---|---|---| |---|---|---|
@ -62,7 +63,7 @@ Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `pendente`
## Models (`portal_api/models.py`) ## 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 ## 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/{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}/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}/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}/` | 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 | | `/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"); - **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 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; - **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); - 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. - 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`. - 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. 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 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 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 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 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 - escrever a sugestão de cada item (`SituacaoItem.sugestao`, texto à parte
da `descricao`, que é só fato); da `descricao`, que é só fato);
@ -28,9 +30,9 @@ from __future__ import annotations
import unicodedata import unicodedata
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date 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 MESES_VENCIDO = 3
MAX_PARCELAS = 24 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])) 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: if valor_pagamento <= 0 or valor_pagamento >= valor_titulo:
return False return None
k = round(valor_titulo / valor_pagamento) k = round(valor_titulo / valor_pagamento)
if k < 2 or k > MAX_PARCELAS: if k < 2 or k > MAX_PARCELAS:
return False return None
# arredondamento de parcela: até 1 centavo por parcela if abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k:
return 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: def _rotulo_titulo(credito: ItemAnalise) -> str:
return f"{credito.data:%d/%m/%Y} ({_moeda(credito.valor)})" 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( 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]: ) -> 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)) 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)) 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_nao_vinculado = Decimal("0")
total_excedente = 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: 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 restante = debito.valor
usados: list[int] = [] usados: list[int] = []
parcela_de = next( # Parcela exata (arredondamento) antes da com diferença, em qualquer título.
(c for c in creditos if saldo_sugerido[c.id] >= debito.valor and _eh_parcela(c.valor, debito.valor)), candidatas = [
None, (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] 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: for credito in ordem:
if restante <= 0: if restante <= 0:
break break
if saldo_sugerido[credito.id] <= 0: if saldo_sugerido[credito.id] <= 0:
continue continue
disponivel_usado += saldo_sugerido[credito.id]
abatido = min(restante, saldo_sugerido[credito.id]) abatido = min(restante, saldo_sugerido[credito.id])
saldo_sugerido[credito.id] -= abatido saldo_sugerido[credito.id] -= abatido
restante -= abatido restante -= abatido
@ -174,10 +282,36 @@ def _analisa_pendentes(
pagos_sugeridos[credito.id].append(debito.id) pagos_sugeridos[credito.id].append(debito.id)
titulos = [c for c in creditos if c.id in usados] titulos = [c for c in creditos if c.id in usados]
if parcela_de and usados == [parcela_de.id]: if escolhida and usados == [parcela_de.id]:
sugestao = f"Possível parcela do título de {_rotulo_titulo(parcela_de)}. Vincule para confirmar." 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: 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: else:
sugestao = "" sugestao = ""
@ -202,6 +336,13 @@ def _analisa_pendentes(
coberto = credito.valor - saldo_sugerido[credito.id] coberto = credito.valor - saldo_sugerido[credito.id]
if not pagos: if not pagos:
sugestao = "" 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: elif saldo_sugerido[credito.id] <= 0:
sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar." sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar."
else: else:
@ -226,9 +367,11 @@ def analisa_conta(
saldo_final: Decimal, saldo_final: Decimal,
saldo_final_arquivo: Decimal, saldo_final_arquivo: Decimal,
data_base: date, data_base: date,
tolerancia: Tolerancia | None = None,
) -> AnaliseConta: ) -> AnaliseConta:
"""`saldo_final` é o recalculado (saldo anterior + créditos - débitos); """`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] pendentes = [i for i in itens if i.vinculo_id is None]
# O saldo de cada pagamento parcial vinculado à mão continua em aberto: # 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 # 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( situacoes, total_aberto, total_vencido, total_nao_vinculado, total_excedente = _analisa_pendentes(
pendentes, data_base pendentes, data_base, tolerancia
) )
alertas: list[AlertaConta] = [] alertas: list[AlertaConta] = []

View File

@ -18,12 +18,23 @@ ordem:
créditos, como NF + juros, somando um débito), limitada a créditos, como NF + juros, somando um débito), limitada a
`MAX_ITENS_COMBINACAO` itens numa janela de `JANELA_DIAS_COMBINACAO` `MAX_ITENS_COMBINACAO` itens numa janela de `JANELA_DIAS_COMBINACAO`
dias, com teto de nós visitados por busca (subset-sum é exponencial). 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. 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 A diferença dentro da tolerância (possível juros/desconto) **não** vira
combinação pequena com diferença até o limite configurado (valor fixo vínculo automático (pedido do usuário, 2026-09-25: antes era a etapa 5, 1:1
**ou** percentual sobre o valor do título, o que for maior), sinalizada ou 2 itens no último segmento): o par fica pendente e `alertas.py` o aponta
como possível juros/desconto. 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 O que sobrar fica pendente, sem vínculo gravado. `alertas.py` distribui os
débitos pendentes sobre os créditos pendentes (parcelas, pagamento parcial) 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_EXATO = "exato"
TIPO_COMBINACAO = "combinacao" TIPO_COMBINACAO = "combinacao"
TIPO_SEQUENCIA = "sequencia" # quitação dos títulos mais antigos (etapa 4)
TIPO_SALDO_ZERADO = "saldo_zerado" TIPO_SALDO_ZERADO = "saldo_zerado"
TIPO_TOLERANCIA = "tolerancia" TIPO_TOLERANCIA = "tolerancia"
TIPO_MANUAL = "manual" TIPO_MANUAL = "manual"
@ -247,12 +259,35 @@ def _combina(
return vinculos 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]: def _sem_vinculo(itens: list[ItemConciliacao], vinculos: list[VinculoSugerido]) -> list[ItemConciliacao]:
usados = {indice for v in vinculos for indice in v.indices} usados = {indice for v in vinculos for indice in v.indices}
return [i for i in itens if i.indice not in usados] 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` """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).""" precisa estar na ordem do arquivo (é ela que define o saldo corrente)."""
vinculos: list[VinculoSugerido] = [] vinculos: list[VinculoSugerido] = []
@ -263,11 +298,9 @@ def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[Vincu
aberto = posicao == len(segmentos) - 1 aberto = posicao == len(segmentos) - 1
locais = _pareia_exato(segmento) locais = _pareia_exato(segmento)
locais += _combina(_sem_vinculo(segmento, locais), TIPO_COMBINACAO, MAX_ITENS_COMBINACAO, None) 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) restantes = _sem_vinculo(segmento, locais)
if not aberto: if not aberto and restantes:
if restantes:
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes)) locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
else:
locais += _combina(restantes, TIPO_TOLERANCIA, MAX_ITENS_TOLERANCIA, tolerancia)
vinculos += locais vinculos += locais
return vinculos 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 = [ TIPO_CHOICES = [
("exato", "Valor exato"), ("exato", "Valor exato"),
("combinacao", "Combinação de valores"), ("combinacao", "Combinação de valores"),
("sequencia", "Quitação dos títulos mais antigos"),
("saldo_zerado", "Compensação até saldo zerado"), ("saldo_zerado", "Compensação até saldo zerado"),
("tolerancia", "Com diferença (juros/desconto)"), ("tolerancia", "Com diferença (juros/desconto)"),
("manual", "Manual"), ("manual", "Manual"),
@ -2822,3 +2823,39 @@ class ConciliacaoFornecedorLancamento(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.data:%d/%m/%Y} {self.natureza} {self.valor}" 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, ConciliacaoFornecedorConta,
ConciliacaoFornecedorLancamento, ConciliacaoFornecedorLancamento,
ConciliacaoFornecedorVinculo, ConciliacaoFornecedorVinculo,
ConciliacaoFornecedorVinculoHistorico,
ContabilAchado, ContabilAchado,
ContabilApuracao, ContabilApuracao,
ContabilApuracaoReprocessamento, ContabilApuracaoReprocessamento,
@ -5301,6 +5302,7 @@ def _conciliacao_analisa(
saldo_final=conta.saldo_final, saldo_final=conta.saldo_final,
saldo_final_arquivo=conta.saldo_final_arquivo, saldo_final_arquivo=conta.saldo_final_arquivo,
data_base=conciliacao.data_base, 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), "valor_em_aberto": str(item.valor_em_aberto),
"descricao": item.descricao, "descricao": item.descricao,
"sugestao": item.sugestao, "sugestao": item.sugestao,
"relacionados": item.relacionados,
} }
def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None: def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None:
@ -5348,6 +5351,9 @@ def _conciliacao_conta_payload(
"valor_em_aberto": str(item.valor_em_aberto), "valor_em_aberto": str(item.valor_em_aberto),
"descricao": item.descricao, "descricao": item.descricao,
"sugestao": item.sugestao, "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 { return {
@ -5495,7 +5501,6 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va
def _conciliacao_grava_contas( def _conciliacao_grava_contas(
conciliacao: ConciliacaoFornecedor, conciliacao: ConciliacaoFornecedor,
razao: conciliacao_parser.RazaoExtraido, razao: conciliacao_parser.RazaoExtraido,
tolerancia: conciliacao_motor.Tolerancia,
) -> None: ) -> None:
"""Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo """Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo
anterior diferente de zero vira um lançamento `eh_saldo_anterior`, 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) conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor)
for pos, linha in enumerate(linhas) for pos, linha in enumerate(linhas)
], ]
tolerancia,
) )
vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create( vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create(
[ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos] [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.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
conciliacao.save() conciliacao.save()
_conciliacao_grava_contas(conciliacao, razao, tolerancia) _conciliacao_grava_contas(conciliacao, razao)
_conciliacao_valida_conciliadas(conciliacao) _conciliacao_valida_conciliadas(conciliacao)
_conciliacao_atualiza_resumo(conciliacao) _conciliacao_atualiza_resumo(conciliacao)
except Exception: except Exception:
@ -5759,6 +5763,38 @@ def _conciliacao_tipo_vinculo_manual(
return tipo, diferenca 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): class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
"""Ajustes manuais numa conta: vincular lançamentos pendentes e marcar """Ajustes manuais numa conta: vincular lançamentos pendentes e marcar
a conta como validada (com observação).""" 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 conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user
) )
ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo) 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) 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"]) @action(detail=True, methods=["post"])
def validar(self, request: Request, pk: str | None = None) -> Response: def validar(self, request: Request, pk: str | None = None) -> Response:
conta = self.get_object() conta = self.get_object()
@ -5817,6 +5891,13 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
vinculo = self.get_object() vinculo = self.get_object()
conta = vinculo.conta conta = vinculo.conta
with transaction.atomic(): with transaction.atomic():
_conciliacao_registra_historico(
conta,
ConciliacaoFornecedorVinculoHistorico.ACAO_DESFEZ,
vinculo,
list(vinculo.lancamentos.all()),
request.user,
)
vinculo.delete() vinculo.delete()
return _conciliacao_resposta_conta(conta) 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.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos)
vinculo.criado_por = request.user vinculo.criado_por = request.user
vinculo.save(update_fields=["tipo", "diferenca", "criado_por"]) 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) return _conciliacao_resposta_conta(conta)

View File

@ -662,16 +662,242 @@ button.conc-kpi.is-active {
width: 28px; width: 28px;
} }
.conc-historico { /* Histórico e sugestão em uma linha só, cortados com reticências (texto
max-width: 380px; 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 { .conc-historico__contrapartida {
display: block;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--text-muted); 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 { .conc-natureza {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
@ -1015,6 +1241,15 @@ button.conc-kpi.is-active {
color: var(--text-primary); 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 { .conc-valor--saldo {
color: var(--text-secondary); 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"' : ""}> <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}"> <button type="button" class="ag-row__open" data-acesso-open="${acesso.id}">
${PID_AG_ROW_ICON} ${PID_AG_ROW_ICON}
<span class="ag-row__label">${acesso.nome}</span> <span class="ag-row__label">${pidEscapeHtml(acesso.nome)}</span>
</button> </button>
<div class="ag-row__admin">${adminHtml}</div> <div class="ag-row__admin">${adminHtml}</div>
</li>`; </li>`;
@ -156,7 +156,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return ` return `
<section class="ag-section" data-secao-id="${secao.id}"> <section class="ag-section" data-secao-id="${secao.id}">
<div class="ag-section__header"> <div class="ag-section__header">
<h3 class="ag-section__title">${secao.nome}</h3> <h3 class="ag-section__title">${pidEscapeHtml(secao.nome)}</h3>
${actionsHtml} ${actionsHtml}
</div> </div>
<ul class="ag-row-list" data-secao-list="${secao.id}">${rowsHtml}</ul> <ul class="ag-row-list" data-secao-list="${secao.id}">${rowsHtml}</ul>
@ -200,7 +200,7 @@ document.addEventListener("DOMContentLoaded", async () => {
(p) => ` (p) => `
<label class="checklist-item"> <label class="checklist-item">
<input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} /> <input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} />
<span>${p.nome}</span> <span>${pidEscapeHtml(p.nome)}</span>
</label>` </label>`
) )
.join(""); .join("");

View File

@ -10,6 +10,22 @@ async function pidEnsureCsrfCookie() {
await fetch(`${PID_API_BASE}/auth/csrf/`, { credentials: "include" }); 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) { function pidErrorMessageFrom(data) {
if (!data) return "Erro ao processar a solicitação."; if (!data) return "Erro ao processar a solicitação.";
if (typeof data === "string") return data; if (typeof data === "string") return data;

View File

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

View File

@ -32,17 +32,199 @@ const PID_CONC_ROTULO_ALERTA = {
const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" }; 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) { function pidConcEscape(texto) {
if (texto == null) return ""; return pidEscapeHtml(texto);
const div = document.createElement("div");
div.textContent = String(texto);
return div.innerHTML;
} }
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) { function pidConcMoeda(valor) {
return PID_CONC_MOEDA.format(Number(valor) || 0); 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. */ /** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */
function pidConcSaldo(valor) { function pidConcSaldo(valor) {
const numero = Number(valor) || 0; const numero = Number(valor) || 0;
@ -79,7 +261,7 @@ function pidConcValorAbertoHtml(situacao) {
function pidConcSituacaoHtml(situacao) { function pidConcSituacaoHtml(situacao) {
if (!situacao) return '<td class="conc-situacao"></td>'; if (!situacao) return '<td class="conc-situacao"></td>';
const sugestao = situacao.sugestao 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>`; 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(); 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 // Validadas ficam fora por padrão (pedido do usuário): a lista mostra
// o que ainda falta revisar. O chip "Validados" as mostra sempre. // o que ainda falta revisar. O chip "Validados" as mostra sempre.
if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false; if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false;
@ -516,7 +699,10 @@ document.addEventListener("DOMContentLoaded", async () => {
return false; return false;
} }
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo); return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
}); }
function contasFiltradas() {
let lista = detalhe.contas.filter(contaPassaFiltro);
if (sortKey) { if (sortKey) {
const extrator = sortValor[sortKey]; const extrator = sortValor[sortKey];
lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir); 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); const estado = estadoDe(resposta.conta.id);
estado.selecionados.clear(); estado.selecionados.clear();
renderCards(); 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) { function linhaLancamentoHtml(lanc, comCheckbox, selecionado) {
const situacao = lanc.situacao; const situacao = lanc.situacao;
const valorAberto = pidConcValorAbertoHtml(situacao); const valorAberto = pidConcValorAbertoHtml(situacao);
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
return ` return `
<tr class="${selecionado ? "is-selecionado" : ""}" data-lanc-id="${lanc.id}"> <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>` : ""} ${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>${pidConcData(lanc.data)}</td>
<td>${pidConcEscape(lanc.sequencia || "—")}</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><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">${pidConcMoeda(lanc.valor)}</td>
${comCheckbox ? `<td class="conc-num conc-valor">${valorAberto}</td>${pidConcSituacaoHtml(situacao)}` : ""} ${comCheckbox ? `<td class="conc-num conc-valor">${valorAberto}</td>${pidConcSituacaoHtml(situacao)}` : ""}
@ -688,6 +895,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="conc-detail__acoes"> <div class="conc-detail__acoes">
<span class="conc-selecao-resumo" data-selecao-resumo></span> <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="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>
</div>`; </div>`;
if (razao.length) { if (razao.length) {
@ -704,12 +912,10 @@ document.addEventListener("DOMContentLoaded", async () => {
painel.appendChild(secaoPend); painel.appendChild(secaoPend);
function linhaRazaoHtml(lanc) { 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 = ` const comum = `
<td>${pidConcData(lanc.data)}</td> <td>${pidConcData(lanc.data)}</td>
<td>${pidConcEscape(lanc.sequencia || "—")}</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><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">${pidConcMoeda(lanc.valor)}</td>
<td class="conc-num conc-valor conc-valor--saldo">${lanc.saldo_apos === null ? "—" : pidConcSaldo(lanc.saldo_apos)}</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-tag">Vinculado #${numeroVinculo.get(lanc.vinculo_id) || "?"}</span>
<span class="conc-vinculado__tipo">${pidConcEscape(vinculo ? vinculo.tipo_rotulo : "")} · ${origem}</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-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> </span>
</td> </td>
</tr>`; </tr>`;
@ -765,6 +972,43 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo"))); tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo")));
tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("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 // "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. // escolha fica no estado da conta e sobrevive ao re-render após um vínculo.
const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]"); 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) => { secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => {
btn.addEventListener("click", (evento) => { btn.addEventListener("click", (evento) => {
evento.stopPropagation(); 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) => { secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id])").forEach((tr) => {
tr.addEventListener("click", (evento) => { tr.addEventListener("click", (evento) => {
if (evento.target.closest("[data-lanc-check]")) return; 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; if (String(window.getSelection ? window.getSelection() : "").trim()) return;
const check = tr.querySelector("[data-lanc-check]"); const check = tr.querySelector("[data-lanc-check]");
check.checked = !check.checked; 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) { function pidDcEscapeHtml(texto) {
const div = document.createElement("div"); return pidEscapeHtml(texto);
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
} }
// Barra de progresso de "Nova Análise"/"Reprocessar" — o processamento roda // 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>${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}</td>
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td> <td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_achados_pendentes}</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>${pidDcEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidDcFormatData(apuracao.criado_em)}</td> <td>${pidDcFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions"> <td class="pa-row-actions">
@ -685,7 +686,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function dcEscapeAttr(value) { function dcEscapeAttr(value) {
return (value === null || value === undefined ? "" : String(value)).replace(/"/g, "&quot;"); return pidEscapeHtml(value);
} }
function dcCriarFiltroColuna(campo) { function dcCriarFiltroColuna(campo) {
@ -1310,8 +1311,8 @@ document.addEventListener("DOMContentLoaded", async () => {
card.innerHTML = ` card.innerHTML = `
<div class="dc-achado-card__main"> <div class="dc-achado-card__main">
<div class="dc-achado-card__titulo"> <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.severidade}">${pidDcEscapeHtml(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.status}">${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)}</span>
${pidDcEscapeHtml(achado.titulo)} ${pidDcEscapeHtml(achado.titulo)}
</div> </div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</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 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__main">
<div class="dc-achado-card__titulo"> <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)} ${pidDcEscapeHtml(achado.titulo)}
</div> </div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div> <div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
@ -3251,7 +3252,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<button <button
type="button" type="button"
class="icon-btn dc-dash-toggle-btn" 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" aria-label="${oculto ? "Mostrar" : "Ocultar"} no relatório"
title="${oculto ? "Mostrar no relatório" : "Ocultar do relatório"}" title="${oculto ? "Mostrar no relatório" : "Ocultar do relatório"}"
${concluida ? "disabled" : ""} ${concluida ? "disabled" : ""}
@ -3775,7 +3776,7 @@ document.addEventListener("DOMContentLoaded", async () => {
: `<input : `<input
type="checkbox" type="checkbox"
class="dc-ind-hub-item__ativo" class="dc-ind-hub-item__ativo"
data-dc-hub-toggle="${definicao.chave}" data-dc-hub-toggle="${pidDcEscapeHtml(definicao.chave)}"
${ativo ? "checked" : ""} ${ativo ? "checked" : ""}
${concluida ? "disabled" : ""} ${concluida ? "disabled" : ""}
aria-label="Ativo nesta apuração" aria-label="Ativo nesta apuração"

View File

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

View File

@ -120,10 +120,10 @@ document.addEventListener("DOMContentLoaded", async () => {
const cardsHtml = favApps const cardsHtml = favApps
.map( .map(
(app) => ` (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)} ${pidIconHtmlFor(app)}
<span class="app-card__label">${app.label}</span> <span class="app-card__label">${pidEscapeHtml(app.label)}</span>
<button type="button" class="app-card__remove" data-fav-remove="${app.id}" aria-label="Remover dos favoritos" draggable="false"> <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> <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> </button>
</article>` </article>`

View File

@ -784,11 +784,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const statusTexto = imp.status === "concluida" ? "Concluída" : "Em revisão"; const statusTexto = imp.status === "concluida" ? "Concluída" : "Em revisão";
tr.innerHTML = ` 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 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>${escapeHtml(imp.codigo_empresa || "—")}</td>
<td>${imp.nome_operadora}</td> <td>${escapeHtml(imp.nome_operadora)}</td>
<td>${tiposLabel(imp.tipos_lancamento)}</td> <td>${escapeHtml(tiposLabel(imp.tipos_lancamento))}</td>
<td><span class="status-pill ${statusClasse}">${statusTexto}</span></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>${criadoEm}</td>
<td class="ips-table-actions"> <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"> <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> <p class="ips-regra-row__nome">${escapeHtml(r.label)}</p>
</div> </div>
<div class="ips-regra-row__actions"> <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>
</div>` </div>`
) )
@ -1829,27 +1829,24 @@ document.addEventListener("DOMContentLoaded", async () => {
tabsEl.innerHTML = tabKeys tabsEl.innerHTML = tabKeys
.map((key) => { .map((key) => {
const label = key === "auditoria" ? "Auditoria" : key === "alteracoes" ? "Alterações" : PID_IPS_TIPO_LABELS[key] || 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(""); .join("");
renderPanels(); 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) { 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) { function escapeHtml(value) {
return (value === null || value === undefined ? "" : String(value)).replace( return pidEscapeHtml(value);
/[&<>"]/g,
(ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]
);
} }
function resizeHandleHtml(campo, target) { function resizeHandleHtml(campo, target) {
@ -2107,7 +2104,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</div> </div>
${concluida ? "" : ` ${concluida ? "" : `
<div class="ips-add-row"> <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> <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 Adicionar linha
</button> </button>
@ -2141,7 +2138,7 @@ document.addEventListener("DOMContentLoaded", async () => {
let acaoHtml = `<span class="ips-empty-cell">—</span>`; let acaoHtml = `<span class="ips-empty-cell">—</span>`;
if (item.resolvida) { if (item.resolvida) {
acaoHtml = `<span class="ips-auditoria-resolvido" title="Vinculado a: ${escapeAttr(item.linha_vinculada_nome || "")}">Resolvido${ 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>`; }</span>`;
} else if (importacaoAtual.status !== "concluida" && PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)) { } 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>`; 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 ` return `
<div class="ips-grid-row" role="row"> <div class="ips-grid-row" role="row">
<div class="ips-grid-cell ips-table-actions" role="cell">${acaoHtml}</div> <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">${escapeHtml(item.nome || "—")}</div>
<div class="ips-grid-cell" role="cell">${item.cpf || "—"}</div> <div class="ips-grid-cell" role="cell">${escapeHtml(item.cpf || "—")}</div>
<div class="ips-grid-cell" role="cell">${item.tipo === "T" ? "Titular" : "Dependente"}</div> <div class="ips-grid-cell" role="cell">${item.tipo === "T" ? "Titular" : "Dependente"}</div>
<div class="ips-grid-cell" role="cell">${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">${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"><span class="ips-auditoria-motivo">${escapeHtml(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">${escapeHtml(item.detalhe || "")}</div>
</div>`; </div>`;
}) })
.join(""); .join("");
@ -2213,8 +2210,8 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr> <tr>
<td class="ips-table-actions">${acaoHtml}</td> <td class="ips-table-actions">${acaoHtml}</td>
<td>${new Date(alt.criado_em).toLocaleString("pt-BR")}</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><span class="ips-alteracao-tipo ips-alteracao-tipo--${escapeAttr(alt.tipo)}">${escapeHtml(PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo)}</span></td>
<td>${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"}</td> <td>${escapeHtml(PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—")}</td>
<td>${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"}</td> <td>${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"}</td>
<td>${detalheHtml}</td> <td>${detalheHtml}</td>
<td>${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"}</td> <td>${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"}</td>
@ -2247,7 +2244,7 @@ document.addEventListener("DOMContentLoaded", async () => {
.map((key) => { .map((key) => {
const html = const html =
key === "auditoria" ? panelHtmlAuditoria() : key === "alteracoes" ? panelHtmlAlteracoes() : panelHtmlParaTipo(key); 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(""); .join("");
wireColumnResize(panelsEl); wireColumnResize(panelsEl);
@ -2290,8 +2287,8 @@ document.addEventListener("DOMContentLoaded", async () => {
<label class="checklist-item"> <label class="checklist-item">
<input type="radio" name="ips-vincular-linha" value="${l.id}" ${vincularLinhaSelecionada === l.id ? "checked" : ""} /> <input type="radio" name="ips-vincular-linha" value="${l.id}" ${vincularLinhaSelecionada === l.id ? "checked" : ""} />
<span class="checklist-item__info"> <span class="checklist-item__info">
<span class="checklist-item__nome">${nomePrincipal}</span> <span class="checklist-item__nome">${escapeHtml(nomePrincipal)}</span>
<span class="checklist-item__departamento">${contexto}</span> <span class="checklist-item__departamento">${escapeHtml(contexto)}</span>
</span> </span>
</label>`; </label>`;
}) })

View File

@ -82,16 +82,14 @@ function pidIndFormatData(iso) {
return new Date(iso).toLocaleDateString("pt-BR"); 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) { function pidIndEscapeHtml(texto) {
const div = document.createElement("div"); return pidEscapeHtml(texto);
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
} }
function pidIndEscapeAttr(texto) { function pidIndEscapeAttr(texto) {
return String(texto == null ? "" : texto) return pidEscapeHtml(texto);
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
} }
// "Cada gerente representa um grupo" (CLAUDE.md): agrupa os colaboradores da // "Cada gerente representa um grupo" (CLAUDE.md): agrupa os colaboradores da
@ -417,7 +415,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.innerHTML = ` tr.innerHTML = `
<td>${pidIndFormatCompetencia(apuracao.competencia)}</td> <td>${pidIndFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_colaboradores}</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>${pidIndEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidIndFormatData(apuracao.criado_em)}</td> <td>${pidIndFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions"> <td class="pa-row-actions">
@ -1793,11 +1791,11 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr> <tr>
<td>${pidIndEscapeHtml(criterio.nome)}</td> <td>${pidIndEscapeHtml(criterio.nome)}</td>
<td>${pidIndEscapeHtml(criterio.departamento_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>${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>${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><span class="status-pill ${criterio.ativo ? "status-pill--ativo" : "status-pill--inativo"}">${criterio.ativo ? "Ativo" : "Inativo"}</span></td>
<td class="pa-row-actions"> <td class="pa-row-actions">
<button type="button" class="icon-btn" data-ind-criterio-toggle="${criterio.id}" title="${criterio.ativo ? "Desativar" : "Ativar"}"> <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) => ` (percentual) => `
<tr> <tr>
<td>${pidIndEscapeHtml(percentual.departamento_nome)}</td> <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_individual)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_grupo)}</td> <td>${pidIndFormatPercentual(percentual.percentual_grupo)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_departamento)}</td> <td>${pidIndFormatPercentual(percentual.percentual_departamento)}</td>

View File

@ -80,7 +80,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function cardHtml(link, index) { 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 isFavorite = favoritos.has(link.id);
const favoriteBtn = ` const favoriteBtn = `
@ -110,9 +110,9 @@ document.addEventListener("DOMContentLoaded", async () => {
${favoriteBtn} ${favoriteBtn}
${adminHtml} ${adminHtml}
</div> </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} ${iconHtml}
<span class="lf-card__label">${link.nome}</span> <span class="lf-card__label">${pidEscapeHtml(link.nome)}</span>
</a> </a>
</article>`; </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" }); 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) { function pidNcfEscapeHtml(texto) {
if (texto == null) return ""; return pidEscapeHtml(texto);
const div = document.createElement("div");
div.textContent = String(texto);
return div.innerHTML;
} }
function pidNcfAtualizarContador(el, quantidade) { function pidNcfAtualizarContador(el, quantidade) {
@ -392,7 +391,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.appendChild(tdBtn); tr.appendChild(tdBtn);
tr.insertAdjacentHTML( tr.insertAdjacentHTML(
"beforeend", "beforeend",
`<td>${oc.codigo}</td> `<td>${pidNcfEscapeHtml(oc.codigo)}</td>
<td>${pidNcfFormatDate(oc.data_emissao)}</td> <td>${pidNcfFormatDate(oc.data_emissao)}</td>
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td> <td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.emissor_relato) || "—"}</td> <td>${pidNcfEscapeHtml(oc.emissor_relato) || "—"}</td>
@ -471,7 +470,7 @@ document.addEventListener("DOMContentLoaded", async () => {
tr.appendChild(tdBtn); tr.appendChild(tdBtn);
tr.insertAdjacentHTML( tr.insertAdjacentHTML(
"beforeend", "beforeend",
`<td>${oc.codigo}</td> `<td>${pidNcfEscapeHtml(oc.codigo)}</td>
<td>${pidNcfFormatDate(oc.data_emissao)}</td> <td>${pidNcfFormatDate(oc.data_emissao)}</td>
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td> <td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
<td>${pidNcfEscapeHtml(oc.responsavel_analise) || "—"}</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 detalhe = await pidApiRequest(`/nao-conformidades/ocorrencias/${item.id}/`);
const acoesHtml = (detalhe.acoes || []).length const acoesHtml = (detalhe.acoes || []).length
? `<h4>Ações vinculadas</h4><ul class="ncf-detail-lista">${detalhe.acoes ? `<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>` .join("")}</ul>`
: ""; : "";
extra.innerHTML = ` extra.innerHTML = `
@ -642,7 +641,7 @@ document.addEventListener("DOMContentLoaded", async () => {
: ""; : "";
tr.insertAdjacentHTML( tr.insertAdjacentHTML(
"beforeend", "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.ocorrencia_area) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td> <td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td>
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"}</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 || ""); 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) { function pidMapEventToNotification(ev) {
return { return {
id: `event-${ev.id}`, id: `event-${ev.id}`,
type: "event", type: "event",
date: pidFormatNotifDate(ev.data), date: pidEscapeHtml(pidFormatNotifDate(ev.data)),
title: ev.titulo.toUpperCase(), title: pidEscapeHtml(ev.titulo.toUpperCase()),
breadcrumb: ev.sou_dono breadcrumb: ev.sou_dono
? "Menu &gt; Calendário Individual" ? "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> <tr>
<td>${p.codigo}</td> <td>${p.codigo}</td>
<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>' : ""} ${p.gerenciaPermissoes ? '<span class="badge-total-access">Acesso total</span>' : ""}
</td> </td>
<td> <td>
@ -229,9 +229,9 @@ document.addEventListener("DOMContentLoaded", async () => {
moduleListEl.innerHTML = PID_MODULES.map((m, idx) => { moduleListEl.innerHTML = PID_MODULES.map((m, idx) => {
const enabled = draftProfile.permissions[m.key].enabled; const enabled = draftProfile.permissions[m.key].enabled;
return ` 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__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> <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>`; </button>`;
}).join(""); }).join("");
@ -241,8 +241,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const match = !term || tool.label.toLowerCase().includes(term); const match = !term || tool.label.toLowerCase().includes(term);
return ` return `
<label class="perm-node perm-node--leaf ${match ? "" : "is-hidden"}"> <label class="perm-node perm-node--leaf ${match ? "" : "is-hidden"}">
<input type="checkbox" data-perm-app="${tool.key}" ${perm.apps[tool.key] ? "checked" : ""} /> <input type="checkbox" data-perm-app="${pidEscapeHtml(tool.key)}" ${perm.apps[tool.key] ? "checked" : ""} />
<span>${tool.label}</span> <span>${pidEscapeHtml(tool.label)}</span>
</label>`; </label>`;
} }
@ -259,12 +259,12 @@ document.addEventListener("DOMContentLoaded", async () => {
return ` return `
<div class="perm-node perm-node--group ${anyMatch ? "" : "is-hidden"}"> <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> <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> </button>
<label> <label>
<input type="checkbox" data-perm-group="${entry.key}" ${groupChecked ? "checked" : ""} /> <input type="checkbox" data-perm-group="${pidEscapeHtml(entry.key)}" ${groupChecked ? "checked" : ""} />
<span>${entry.label}</span> <span>${pidEscapeHtml(entry.label)}</span>
</label> </label>
</div> </div>
<div class="perm-children perm-children--nested" data-perm-children ${subgroupExpanded ? "" : "hidden"}> <div class="perm-children perm-children--nested" data-perm-children ${subgroupExpanded ? "" : "hidden"}>
@ -295,7 +295,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
<label> <label>
<input type="checkbox" data-perm-role="module" ${perm.enabled ? "checked" : ""} /> <input type="checkbox" data-perm-role="module" ${perm.enabled ? "checked" : ""} />
<span>${moduleLabel}</span> <span>${pidEscapeHtml(moduleLabel)}</span>
</label> </label>
</div> </div>
${ ${
@ -339,8 +339,8 @@ document.addEventListener("DOMContentLoaded", async () => {
(u) => ` (u) => `
<tr> <tr>
<td><input type="checkbox" data-pa-users-select="${u.id}" ${state.selected.has(u.id) ? "checked" : ""} /></td> <td><input type="checkbox" data-pa-users-select="${u.id}" ${state.selected.has(u.id) ? "checked" : ""} /></td>
<td>${u.nome}</td> <td>${pidEscapeHtml(u.nome)}</td>
<td>${u.email || "—"}</td> <td>${pidEscapeHtml(u.email || "—")}</td>
</tr>` </tr>`
) )
.join("") .join("")
@ -410,9 +410,9 @@ document.addEventListener("DOMContentLoaded", async () => {
? items ? items
.map( .map(
(a) => ` (a) => `
<button type="button" class="pa-app-result-item" data-pa-app-module="${a.moduleKey}" data-pa-app-key="${a.key}"> <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">${a.label}</span> <span class="pa-app-result-item__label">${pidEscapeHtml(a.label)}</span>
<span class="pa-app-result-item__module">${a.moduleLabel}</span> <span class="pa-app-result-item__module">${pidEscapeHtml(a.moduleLabel)}</span>
</button>` </button>`
) )
.join("") .join("")
@ -428,7 +428,7 @@ document.addEventListener("DOMContentLoaded", async () => {
appManageThead.innerHTML = ` appManageThead.innerHTML = `
<tr> <tr>
<th>Perfil</th> <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>`; </tr>`;
const rows = profiles const rows = profiles
@ -442,11 +442,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const perm = p.permissions[entry.moduleKey]; const perm = p.permissions[entry.moduleKey];
return ` return `
<tr> <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 ${tools
.map( .map(
(tool) => ` (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" : "" perm.apps[tool.key] ? "checked" : ""
} /></td>` } /></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( const botoes = [`<button type="button" class="ram-lookup-depto-btn${activeDepto === "" ? " is-active" : ""}" data-depto="">Todos</button>`].concat(
departamentos.map( departamentos.map(
(d) => (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(""); deptoFiltrosEl.innerHTML = botoes.join("");
@ -115,9 +115,9 @@ document.addEventListener("DOMContentLoaded", async () => {
.map( .map(
(r) => ` (r) => `
<tr> <tr>
<td>${r.nome_exibicao}</td> <td>${pidEscapeHtml(r.nome_exibicao)}</td>
<td>${r.departamento_exibicao || "—"}</td> <td>${pidEscapeHtml(r.departamento_exibicao || "—")}</td>
<td>${r.numero_exibicao || "—"}</td> <td>${pidEscapeHtml(r.numero_exibicao || "—")}</td>
</tr>` </tr>`
) )
.join("") .join("")

View File

@ -232,7 +232,7 @@ document.addEventListener("DOMContentLoaded", async () => {
let editingId = null; let editingId = null;
function usuarioOptionsHtml() { 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) { function rowHtml(ramal) {
@ -241,11 +241,11 @@ document.addEventListener("DOMContentLoaded", async () => {
const rowClass = aniversario ? "ram-row--aniversario" : ausente ? "ram-row--ausente" : ""; const rowClass = aniversario ? "ram-row--aniversario" : ausente ? "ram-row--ausente" : "";
const nomeHtml = aniversario const nomeHtml = aniversario
? `${ramal.nome_exibicao} <span class="ram-badge ram-badge--aniversario">🎉 Feliz Aniversário</span>` ? `${pidEscapeHtml(ramal.nome_exibicao)} <span class="ram-badge ram-badge--aniversario">🎉 Feliz Aniversário</span>`
: ramal.nome_exibicao; : pidEscapeHtml(ramal.nome_exibicao);
const numeroHtml = ramal.numero_exibicao const numeroHtml = ramal.numero_exibicao
? ramal.numero_exibicao ? pidEscapeHtml(ramal.numero_exibicao)
: `<span class="ram-badge ram-badge--pendente">Pendente</span>`; : `<span class="ram-badge ram-badge--pendente">Pendente</span>`;
const ausenteBadgeHtml = ausente const ausenteBadgeHtml = ausente
@ -270,7 +270,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return ` return `
<tr class="${rowClass}"> <tr class="${rowClass}">
<td>${nomeHtml}</td> <td>${nomeHtml}</td>
<td>${ramal.departamento_exibicao || "—"}</td> <td>${pidEscapeHtml(ramal.departamento_exibicao || "—")}</td>
<td>${ramalHtml}</td> <td>${ramalHtml}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td> <td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`; </tr>`;
@ -317,10 +317,10 @@ document.addEventListener("DOMContentLoaded", async () => {
return ` return `
<tr> <tr>
<td>${tel.nome}</td> <td>${pidEscapeHtml(tel.nome)}</td>
<td>${tel.ramal || "—"}</td> <td>${pidEscapeHtml(tel.ramal || "—")}</td>
<td>${tel.telefone || "—"}</td> <td>${pidEscapeHtml(tel.telefone || "—")}</td>
<td>${tel.observacoes || "—"}</td> <td>${pidEscapeHtml(tel.observacoes || "—")}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td> <td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`; </tr>`;
} }
@ -379,9 +379,9 @@ document.addEventListener("DOMContentLoaded", async () => {
return ` return `
<tr> <tr>
<td>${fun.comando}</td> <td>${pidEscapeHtml(fun.comando)}</td>
<td>${fun.funcao}</td> <td>${pidEscapeHtml(fun.funcao)}</td>
<td>${fun.resumo || "—"}</td> <td>${pidEscapeHtml(fun.resumo || "—")}</td>
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td> <td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
</tr>`; </tr>`;
} }
@ -480,7 +480,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
function fillAusenciaViewFields(ausencia, ramal) { 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 || ""; ausViewDataInicio.value = ausencia.data_inicio || "";
ausViewHoraInicio.value = ausencia.hora_inicio ? ausencia.hora_inicio.slice(0, 5) : ""; ausViewHoraInicio.value = ausencia.hora_inicio ? ausencia.hora_inicio.slice(0, 5) : "";
ausViewDataFim.value = ausencia.data_fim || ""; 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 // Célula vazia fica destacada (itálico, cor apagada) — ordenar a coluna
// agrupa quem ainda não tem aquele código/ramal cadastrado. // agrupa quem ainda não tem aquele código/ramal cadastrado.
function celulaCodigo(valor) { function celulaCodigo(valor) {
return valor ? valor : '<span class="ua-campo-vazio">—</span>'; return valor ? pidEscapeHtml(valor) : '<span class="ua-campo-vazio">—</span>';
} }
function renderList() { function renderList() {
@ -143,14 +143,14 @@ document.addEventListener("DOMContentLoaded", async () => {
.map( .map(
(u) => ` (u) => `
<tr> <tr>
<td>${u.username}</td> <td>${pidEscapeHtml(u.username)}</td>
<td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${u.nome}</a></td> <td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${pidEscapeHtml(u.nome)}</a></td>
<td>${celulaCodigo(u.codigo_folha)}</td> <td>${celulaCodigo(u.codigo_folha)}</td>
<td>${celulaCodigo(u.codigo_questor)}</td> <td>${celulaCodigo(u.codigo_questor)}</td>
<td>${celulaCodigo(u.codigo_tareffa)}</td> <td>${celulaCodigo(u.codigo_tareffa)}</td>
<td>${celulaCodigo(u.codigo_contabit)}</td> <td>${celulaCodigo(u.codigo_contabit)}</td>
<td>${celulaCodigo(u.ramal)}</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><span class="status-pill ${u.is_active ? "status-pill--ativo" : "status-pill--inativo"}">${u.is_active ? "Ativo" : "Inativo"}</span></td>
<td> <td>
<div class="pa-row-actions"> <div class="pa-row-actions">
@ -180,7 +180,7 @@ document.addEventListener("DOMContentLoaded", async () => {
(p) => ` (p) => `
<label class="checklist-item"> <label class="checklist-item">
<input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} /> <input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} />
<span>${p.nome}</span> <span>${pidEscapeHtml(p.nome)}</span>
</label>` </label>`
) )
.join(""); .join("");
@ -211,7 +211,7 @@ document.addEventListener("DOMContentLoaded", async () => {
<div class="ua-departamento-item"> <div class="ua-departamento-item">
<label class="checklist-item"> <label class="checklist-item">
<input type="checkbox" value="${d.id}" ${selectedIds.includes(d.id) ? "checked" : ""} /> <input type="checkbox" value="${d.id}" ${selectedIds.includes(d.id) ? "checked" : ""} />
<span>${d.nome}</span> <span>${pidEscapeHtml(d.nome)}</span>
</label> </label>
<div class="ua-departamento-item__actions"> <div class="ua-departamento-item__actions">
<button type="button" class="ua-departamento-action" data-dep-edit="${d.id}" aria-label="Renomear departamento"> <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}`; const dateLabel = horario ? `${day}/${month} ${horario}` : `${day}/${month}`;
return ` return `
<div class="widget-agenda-row"> <div class="widget-agenda-row">
<span class="widget-agenda-row__date">${dateLabel}</span> <span class="widget-agenda-row__date">${pidEscapeHtml(dateLabel)}</span>
<span class="widget-agenda-row__title">${ev.titulo}</span> <span class="widget-agenda-row__title">${pidEscapeHtml(ev.titulo)}</span>
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${ev.dono_nome}</span>` : ""} ${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${pidEscapeHtml(ev.dono_nome)}</span>` : ""}
</div>`; </div>`;
}) })
.join(""); .join("");
@ -101,12 +101,12 @@ document.addEventListener("DOMContentLoaded", async () => {
${favoritos ${favoritos
.map((link) => { .map((link) => {
const iconHtml = link.icone const iconHtml = link.icone
? `<img class="widget-links-list__icon" src="${link.icone}" alt="" />` ? `<img class="widget-links-list__icon" src="${pidEscapeHtml(link.icone)}" alt="" />`
: PID_WIDGET_LINK_DEFAULT_ICON; : PID_WIDGET_LINK_DEFAULT_ICON;
return ` 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} ${iconHtml}
<span>${link.nome}</span> <span>${pidEscapeHtml(link.nome)}</span>
</a>`; </a>`;
}) })
.join("")} .join("")}
@ -133,7 +133,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const height = widget.altura || PID_WIDGET_DEFAULT_HEIGHT; const height = widget.altura || PID_WIDGET_DEFAULT_HEIGHT;
const body = widgetBodyCache[widget.tipo] || ""; const body = widgetBodyCache[widget.tipo] || "";
return ` 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"> <div class="widget-card__header" draggable="true">
<h3>${type.label}</h3> <h3>${type.label}</h3>
<button type="button" class="widget-card__remove" data-widget-remove="${widget.tipo}" aria-label="Remover widget" draggable="false"> <button type="button" class="widget-card__remove" data-widget-remove="${widget.tipo}" aria-label="Remover widget" draggable="false">