Revisão do js.
This commit is contained in:
parent
0287de814e
commit
ce611ad9ca
4
.gitignore
vendored
4
.gitignore
vendored
@ -59,3 +59,7 @@ desktop.ini
|
||||
|
||||
# Configuração local do Claude Code
|
||||
.claude/settings.local.json
|
||||
|
||||
# Regras gerais de UI (uso local) e documentos de revisão gerados a partir delas
|
||||
AGENT.MD
|
||||
docs/revisao-interface-*.docx
|
||||
|
||||
@ -162,6 +162,8 @@ Pacotes Python puros (sem ORM), um por ferramenta — cada um com seu próprio `
|
||||
|
||||
`pidApiRequest` (em `api.js`) detecta `body instanceof FormData` e, nesse caso, **não** faz `JSON.stringify` nem define `Content-Type` manualmente — deixa o browser montar o `multipart/form-data` com o boundary certo. Usado pelo upload de `icone` em Links & Ferramentas e pelos dois arquivos anexados em "Nova Importação" de Plano de Saúde; todo o resto da API é JSON puro. A única resposta binária da API (`/importacoes-plano-saude/{id}/gerar/`, que devolve CSV/ZIP) não passa por `pidApiRequest` — usa um `fetch` manual dedicado (ver seção "Importação de Plano de Saúde").
|
||||
|
||||
**Escape de HTML obrigatório (`pidEscapeHtml`, `api.js`)**: todo dado vindo da API, do usuário ou de arquivo anexado que entra em HTML montado por template string (`innerHTML`, `insertAdjacentHTML`) passa por `pidEscapeHtml(texto)`, que trata `& < > " '` e serve para conteúdo **e** atributo. Sem isso, um texto com HTML (título de compromisso, nome de link, observação, dado do arquivo da operadora) vira código executado no navegador de quem abre a tela, inclusive de outros usuários (XSS armazenado). Corrigido em todo o frontend na revisão de interface de 2026-09-25 (ver `plano.md`). Regras: escapar o **dado**, num único ponto (sem escape duplo), nunca o HTML que o próprio código monta (ícones, fragmentos); dado atribuído por `.textContent`/`.value` já é seguro e não se escapa; **texto rico sanitizado no servidor com `nh3`** (Ajuda, observações de Acessos Gerais, Resumo do Fechamento) é HTML intencional e **não** se escapa. As funções locais de escape que ainda existem (`pidDcEscapeHtml`, `pidNcfEscapeHtml`, `pidIndEscapeHtml`, `pidConcEscape`, `escapeHtml`/`escapeAttr` do Plano de Saúde) delegam a ela; a versão antiga por `textContent`/`innerHTML` não escapava aspas e não deve voltar a ser usada.
|
||||
|
||||
`pidApplyAccessVisibility` não recalcula mais união de permissões no cliente — usa `me.permissoes_efetivas`, já unida no backend (`permissoes_efetivas()` em `views.py`).
|
||||
|
||||
## Páginas
|
||||
|
||||
55
plano.md
55
plano.md
@ -478,6 +478,61 @@ a doc dela junto. Na prática quase nunca traz: o código de todas elas vive em
|
||||
Editar os models ou as views do Relatório Contábil não dispara o `CLAUDE.md`
|
||||
dele.
|
||||
|
||||
### 150. Escape de HTML em todo o frontend (XSS) (2026-09-25)
|
||||
|
||||
Primeira correção da revisão de interface feita com a skill `web-design-guidelines`
|
||||
(relatório em `docs/revisao-interface-2026-09-25.docx`, item 1.1; versão
|
||||
explicada em `docs/revisao-interface-2026-09-25-resumo.docx`). O usuário pediu
|
||||
para aplicar **só este item** por enquanto.
|
||||
|
||||
**Problema**: em várias telas, dado vindo da API, do usuário ou de arquivo
|
||||
anexado entrava em `innerHTML` sem escape, ou com uma função de escape que não
|
||||
tratava aspas e era usada dentro de atributo. Um texto com HTML virava código
|
||||
executado no navegador de quem abria a tela. Nos casos de dado compartilhado
|
||||
(compromisso com visibilidade "todos" no sino e no widget, links, ramais), no
|
||||
navegador de **outros usuários** (XSS armazenado).
|
||||
|
||||
**Correção**:
|
||||
|
||||
- Função compartilhada `pidEscapeHtml(texto)` em `static/js/api.js` (carregado
|
||||
em todos os 15 shells, antes dos outros scripts), tratando `& < > " '`, para
|
||||
conteúdo e atributo.
|
||||
- As funções locais passaram a delegar a ela: `pidDcEscapeHtml`/`dcEscapeAttr`
|
||||
(Relatório Contábil), `pidNcfEscapeHtml` (Não Conformidades),
|
||||
`pidIndEscapeHtml`/`pidIndEscapeAttr` (Indicador), `pidConcEscape`/
|
||||
`pidConcEscapeAttr` (Conciliação), `escapeHtml`/`escapeAttr` (Plano de Saúde).
|
||||
As três primeiras e `pidConcEscape` eram baseadas em `textContent` e deixavam
|
||||
`"` passar (observação do contador em `aria-label`, tipo de ocorrência em
|
||||
`value`, nome de quem validou em `title`).
|
||||
- Escape aplicado nas interpolações sem nenhum escape: `notifications.js`
|
||||
(título/dono do compromisso, escapados uma vez na criação do objeto),
|
||||
`widgets.js`, `dual-select.js`, `favorites.js`, `links-ferramentas.js`,
|
||||
`acessos-gerais.js`, `calendar-individual.js`, `ramais-lookup.js`,
|
||||
`ramais.js`, `profiles.js`, `users-admin.js`, `importacao-plano-saude.js`
|
||||
(auditoria com nome/CPF/detalhe do arquivo da operadora, histórico, abas,
|
||||
alterações), `nao-conformidades.js` (códigos), `indicador-desempenho.js` e
|
||||
`dashboard-contabil.js` (rótulos com valor de reserva vindo da API, chaves de
|
||||
indicador personalizado). `custo-contratacao.js`, `ajuda-aplicacao.js`,
|
||||
`account.js`, `seasonal-theme.js`, `search.js`, `access.js`, `sidebar.js`,
|
||||
`auth.js` e `events.js` foram revisados e não tinham ponto inseguro.
|
||||
- **Não escapado de propósito**: texto rico sanitizado no servidor com `nh3`
|
||||
(Ajuda, observações de Acessos Gerais, Resumo do Fechamento), HTML montado pelo
|
||||
próprio código (ícones, fragmentos), dado atribuído por `textContent`/`value`,
|
||||
mensagens de `pidConfirm`/`pidAlert` (o modal usa `textContent`) e ids
|
||||
numéricos. `href` com `javascript:` não é risco porque `LinkFerramenta.url` e
|
||||
`AcessoGeral.url` são `URLField` (só http/https/ftp).
|
||||
- Única mudança visível: onde um campo vinha vazio (`null`), a célula mostrava
|
||||
"undefined" em alguns pontos do Plano de Saúde e agora fica vazia.
|
||||
|
||||
**Validação**: só frontend, sem banco, API ou migration, e não exige reiniciar o
|
||||
`runserver`. Sintaxe dos 17 arquivos alterados conferida com um analisador de
|
||||
JavaScript (esprima, instalado só na pasta de rascunho da sessão). **Não
|
||||
testado no navegador** (sem renderização neste ambiente). Roteiro sugerido:
|
||||
cadastrar textos como `Teste <b>negrito</b> "aspas" & cia` em compromisso, link,
|
||||
ramal, perfil, acesso e observação, e conferir que aparecem **literalmente** em
|
||||
todas as telas onde são exibidos, sem `&` sobrando. A regra ficou registrada
|
||||
no `CLAUDE.md` ("Frontend consumindo a API").
|
||||
|
||||
## Limitações conhecidas / decisões assumidas
|
||||
|
||||
- Calendário De Paula (empresa) não tem mais versão interna — depende
|
||||
|
||||
@ -10,6 +10,7 @@ from .models import (
|
||||
ConciliacaoFornecedorConta,
|
||||
ConciliacaoFornecedorLancamento,
|
||||
ConciliacaoFornecedorVinculo,
|
||||
ConciliacaoFornecedorVinculoHistorico,
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilApuracaoReprocessamento,
|
||||
@ -334,6 +335,12 @@ class ConciliacaoFornecedorVinculoAdmin(admin.ModelAdmin):
|
||||
list_filter = ("tipo",)
|
||||
|
||||
|
||||
@admin.register(ConciliacaoFornecedorVinculoHistorico)
|
||||
class ConciliacaoFornecedorVinculoHistoricoAdmin(admin.ModelAdmin):
|
||||
list_display = ("conta", "acao", "tipo_rotulo", "usuario", "criado_em")
|
||||
list_filter = ("acao",)
|
||||
|
||||
|
||||
@admin.register(ConciliacaoFornecedorLancamento)
|
||||
class ConciliacaoFornecedorLancamentoAdmin(admin.ModelAdmin):
|
||||
list_display = ("conta", "data", "natureza", "valor", "historico", "vinculo")
|
||||
|
||||
@ -102,3 +102,57 @@ A pedido do usuário, "Natureza" + "Valor" viraram colunas "Débito" + "Crédito
|
||||
### Rodada 19 — Toggle "Ocultar conciliados" no razão da conta expandida (2026-09-25)
|
||||
|
||||
Pedido do usuário: um botão para esconder o que já está conciliado no razão desde a primeira pendência, **desmarcado por padrão**. Checkbox "Ocultar conciliados" (estilo `.conc-toggle`, o mesmo de "Mostrar validadas") ao lado do título "Razão desde ... · N não conciliado(s)", com a contagem de linhas vinculadas no período; só aparece quando há alguma. Esconde as `tr.conc-linha--vinculada` pela classe `is-ocultando-conciliados` na tabela; o estado fica por conta (`ocultarConciliados` em `estadoDe`) e é mantido depois de vincular/desfazer. Só frontend (JS/CSS), sem mudança de API.
|
||||
|
||||
### Rodada 20 — Sugestão de parcela com pequena diferença (2026-09-25)
|
||||
|
||||
Pergunta do usuário (ROZINI): por que o pagamento de 14/07 (R$ 2.163,55) era sugerido como parcela do título de 28/05 (R$ 8.654,22) e o de 26/06 (R$ 2.163,57) só como "pode ser pagamento do título"? A regra de parcela aceitava só o arredondamento da divisão (até 1 centavo por parcela no total: 4 × 2.163,57 = 8.654,28, R$ 0,06 acima, limite R$ 0,04). O usuário escolheu, entre aumentar a tolerância e mostrar a diferença, **mostrar a diferença**: `_eh_parcela()` virou `_parcela()`, que devolve a parcela de referência e a diferença; parcela exata continua tendo prioridade, e sem ela vale o pagamento a até 2% (`TOLERANCIA_PERCENTUAL_PADRAO` de `motor.py`, só o percentual) da parcela arredondada mais próxima. Texto novo: "Possível parcela do título de 28/05/2026 (R$ 8.654,22), com R$ 0,01 a mais que a parcela de R$ 2.163,56 (juros/desconto?). Vincule para confirmar." Só sugestão, nenhum valor ou vínculo muda. Testado com os três lançamentos da ROZINI chamando `_analisa_pendentes` direto.
|
||||
|
||||
### Rodada 21 — Linhas do razão com altura fixa (2026-09-25)
|
||||
|
||||
Pedido do usuário: em tela mais estreita, o histórico quebrava em 2 linhas (mais a contrapartida numa terceira) e a sugestão longa também, deixando as linhas do razão altas. Histórico, contrapartida e sugestão passaram a ficar em uma linha cada, com reticências e o texto completo ao passar o mouse (`title`). Colunas Histórico (30%) e Situação (34%) com `max-width: 0`, mesmo truque da coluna de fornecedor, para cederem espaço sem alargar a tabela. Helper novo `pidConcHistoricoHtml()` (usado no razão e nos blocos de vínculo) e `pidConcEscapeAttr()` (o `pidConcEscape` não escapa aspas). Contrapartida ao lado do histórico foi descartada (ajuda a distinguir pagamento de relance). Sem renderização neste ambiente: conferir por print.
|
||||
|
||||
Na mesma rodada, depois do print do usuário: o `title` nativo do navegador não estava no padrão visual do Portal. Trocado por um tooltip próprio (`data-conc-tooltip` + `.conc-tooltip`, mesmo visual de `.dc-hover-tooltip__texto` do Relatório Contábil): um único elemento `position: fixed` no body, delegado no document (`mouseover`/`mouseout`), posicionado acima do texto (abaixo se não couber), escondido ao rolar, e só exibido quando o texto está de fato cortado (`scrollWidth > clientWidth`). Depois de mais um print, o balão ficou apagado sobre o fundo escuro: ganhou borda na cor do tema (`rgba(var(--accent-rgb), 0.55)`) e `--shadow-md` no lugar da borda neutra e `--shadow-sm`.
|
||||
|
||||
### Rodada 22 — Destaque das linhas da sugestão no hover (2026-09-25)
|
||||
|
||||
Pedido do usuário: ao passar o mouse na sugestão, destacar as linhas envolvidas, como já acontece com um vínculo. `SituacaoItem.relacionados` (ids do outro lado da distribuição sugerida, já calculado em `alertas.py`) passou a ir no payload de `situacao` (e no do saldo de vínculo parcial); id negativo é o saldo de um vínculo parcial (`-vinculo.id`) e destaca as linhas daquele vínculo. Na tela, `data-conc-relacionados` na `.conc-sugestao` e a classe `is-sugestao-grupo` na própria linha + relacionadas. Cor dourada (`--gold-rgb`), não o verde do vínculo (sugestão não é vínculo) nem o roxo, que já é o de linha selecionada.
|
||||
|
||||
### Rodada 23 — Histórico de vinculações e desvinculações (2026-09-25)
|
||||
|
||||
Pedido do usuário: saber quando houve vinculação ou desvinculação manual e com quais seq., por um botão "i" no canto. Model novo `ConciliacaoFornecedorVinculoHistorico` (migration `0084_conciliacao_historico_vinculos`, aplicada) gravado em `vincular/`, `adicionar/` e no `DELETE` do vínculo (inclusive desfazer vínculo automático), com usuário, data/hora, tipo e cópia dos lançamentos (o vínculo desfeito é apagado). Endpoint `GET /conciliacoes-fornecedores-contas/{id}/historico-vinculos/`. Na tela, botão "i" à direita de "Vincular selecionados" (tooltip "Histórico de vinculações", o `data-conc-tooltip` ganhou `data-conc-tooltip-sempre` para botão só com ícone) e modal com a lista. Decisões minhas, não confirmadas: histórico **por conta** (não por lançamento); vínculos do motor na importação não entram. Testado pela API com a ROZINI (vincular → acrescentar → desfazer → histórico) dentro de transação desfeita no fim. Sem renderização do modal neste ambiente.
|
||||
|
||||
### Rodada 24 — Sugestão prioriza título de mesmo valor (2026-09-25)
|
||||
|
||||
Pergunta do usuário (ROZINI): depois de desfazer o vínculo automático exato do pagamento de 09/06 (R$ 6.450,57), a sugestão apontava o título de 28/05 (R$ 8.654,22) em vez do título de 09/06 de mesmo valor. Causa: a distribuição das sugestões só tentava parcela e, sem ela, o título mais antigo com saldo; valor exato nem era considerado, e o erro se propagava para as sugestões dos dois títulos. Correção: passada própria de **pares de mesmo valor** antes da ordem por data (desempate do `_pareia_exato` do motor), com a sugestão "Mesmo valor do título de ...". Na mesma rodada, a pedido do usuário, o último recurso (título mais antigo com saldo) ganhou texto que deixa claro ser sugestão fraca: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ...". Testado chamando `_analisa_pendentes` com os lançamentos do print.
|
||||
|
||||
### Rodada 25 — Histórico e contrapartida no histórico de vinculações (2026-09-25)
|
||||
|
||||
Pedido do usuário, entre quatro melhorias sugeridas para o modal (escolheu só esta): cada lançamento do histórico de vinculações mostra o histórico e a contrapartida, em uma linha cada com reticências e o tooltip do Portal. Lidos na hora pelo id no `historico-vinculos/` (o lançamento não muda depois da importação), então valem também para os registros já gravados. Grid do item ganhou a coluna; a etiqueta Débito/Crédito deixou de esticar (envolvida num `span`). `.conc-tooltip` subiu para `z-index: 200`, acima do `.modal-overlay`. **Sugeridas e não aplicadas** (não reapresentar como novidade): resultado do vínculo (totais/"resta R$ X"), origem do vínculo desfeito (quem criou e quando) e trocar "Manual · manual" por só "Manual".
|
||||
|
||||
### Rodada 26 — Duplo clique na sugestão seleciona as linhas envolvidas (2026-09-25)
|
||||
|
||||
Pedido do usuário: duplo clique na sugestão seleciona a própria linha e as relacionadas (as mesmas do destaque dourado da rodada 22); outro duplo clique remove a seleção. Regra: se todas do grupo já estão marcadas, desmarca; senão, marca todas. Só entram linhas não vinculadas (o saldo de vínculo parcial, id negativo, fica só no destaque). Usa o `change` de cada checkbox, então soma da barra e "Vincular selecionados" se atualizam sozinhos. Efeito colateral deliberado: clique simples **dentro da sugestão** deixa de marcar/desmarcar a linha (senão o duplo clique marcaria e desmarcaria antes); fora dela, a linha continua clicável.
|
||||
|
||||
### Rodada 27 — Quantidade de parcelas na sugestão (2026-09-25)
|
||||
|
||||
Pedido do usuário: a sugestão de possível parcela passa a dizer em quantas vezes. `_parcela()` devolve também `k` (`(k, referência, diferença)`). Textos: "Possível parcela do título de 01/07/2026 (R$ 1.656,80), em 3x de R$ 552,27. Vincule para confirmar." e, com diferença, "... em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?). ...". No exemplo do usuário (AUDIO 5, R$ 1.656,80 / R$ 552,27) o cálculo é 3x, não 4x como ele supôs (3 × 552,27 = 1.656,81). Não indica **qual** parcela é (1ª, 2ª...), porque o razão não traz essa informação.
|
||||
|
||||
### Rodada 28 — Soma e saldo residual quando a sugestão cita vários títulos (2026-09-25)
|
||||
|
||||
Pedido do usuário (pagamento URBANO de R$ 616,82 sugerido contra 10 títulos de frete): quando a sugestão de último recurso cita mais de um título, mostrar a soma e o saldo residual. Texto acrescentado: "Soma: R$ 717,41; saldo residual de R$ 100,59 em aberto." (ou "o pagamento excede em R$ X" quando nem todos os títulos cobrem o pagamento). A soma é do saldo **disponível** de cada título no momento (`disponivel_usado`), que pode ser menor que o valor cheio exibido no rótulo quando um pagamento anterior da distribuição já abateu parte dele. Com um título só, o texto não muda.
|
||||
|
||||
### Rodada 29 — Detalhe do vínculo pelo "i" da linha (2026-09-25)
|
||||
|
||||
Pedido do usuário: um "i" na linha vinculada para ver o que o vínculo automático considerou (as outras linhas do vínculo podem estar longe no razão; o destaque no hover não basta). Botão pequeno ao lado de "Desfazer" abre o modal "Vínculo #N": tipo, automático/manual (com o nome de quem criou, no manual), o critério usado em texto (`PID_CONC_CRITERIO_VINCULO`, um por tipo do motor), os lançamentos (mesma linha do histórico de vinculações, com histórico e contrapartida) e os totais de créditos, débitos e diferença. Só frontend: tudo já vinha no payload da conta. O modal do histórico virou genérico (`pidConcAbreModalInfo(titulo, subtitulo)`), usado pelos dois.
|
||||
|
||||
### Rodada 30 — Diferença de juros/desconto vira sugestão, não vínculo automático (2026-09-25)
|
||||
|
||||
Pedido do usuário (BRASPRESS, título R$ 133,81 × pagamento R$ 127,61 vinculados automaticamente "com diferença"): no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros. A etapa 5 do motor (tolerância) saiu de `concilia()`, que deixou de receber `tolerancia` (e `_conciliacao_grava_contas` também). Em `alertas.py`, passada nova de **valor próximo** depois da de mesmo valor: 1:1 dentro da tolerância da conciliação, menor diferença primeiro; sugestão nos dois lados ("Possível pagamento do título de 11/05/2026 (R$ 133,81), com R$ 6,20 a menos (possível desconto). Vincule para confirmar." / "Possível pagamento em 26/06/2026 (R$ 127,61), ..."). `analisa_conta` recebe a `Tolerancia` da conciliação. Vale para **novas importações**; os 21 vínculos `tolerancia` já gravados (uma conciliação) **não foram alterados**: o usuário decidiu deixá-los e refazer a conciliação com uma nova importação; o texto do critério no modal "Vínculo #N" avisa que o critério só valeu até esta data. Risco conhecido: com o piso fixo de R$ 10, em títulos pequenos (fretes de R$ 40) a sugestão de valor próximo pode aparecer por acaso; se incomodar, usar só o percentual, como na parcela.
|
||||
|
||||
### Rodada 31 — Conta validada sai da lista na hora (2026-09-25)
|
||||
|
||||
Bug relatado pelo usuário: ao marcar "Validado", a conta não ia para os validados na hora, às vezes só depois de recarregar a página. Causa: `aplicarRespostaConta()` só redesenhava a linha no lugar (`substituirConta()`), sem reaplicar o filtro; a conta só sumia no próximo render da lista inteira (trocar chip, buscar, recarregar). A regra do filtro virou `contaPassaFiltro()` (usada por `contasFiltradas()` e depois de cada ajuste); conta que não cabe mais sai com fade de 160ms (`removerConta()`, classe `is-saindo`), com a mensagem de lista vazia quando for a última. Vale para validar/desvalidar e para vincular/desfazer que mude a situação com um chip ativo.
|
||||
|
||||
### Rodada 32 — Quitação dos títulos mais antigos como vínculo automático (2026-09-25)
|
||||
|
||||
Pergunta do usuário (BRASPRESS): o pagamento de R$ 234,40 era exatamente a soma dos 4 títulos mais antigos em aberto (a própria sugestão mostrava "saldo residual de R$ 0,00"), por que não foi vinculado? Causa: a combinação só considera os 12 candidatos mais próximos em data (`MAX_CANDIDATOS_COMBINACAO`), e esses 4 estavam nas posições 16 a 19 entre 80. Proposta aceita pelo usuário: etapa nova no motor, `_quita_mais_antigos()` (`TIPO_SEQUENCIA = "sequencia"`, "Quitação dos títulos mais antigos"; migration `0085_conciliacao_vinculo_sequencia`, aplicada, só as choices), depois da combinação e antes da compensação do segmento fechado: pagamento contra a sequência dos títulos mais antigos em aberto, só com soma exata e 2 títulos ou mais, sem limite de itens. Rodando o motor novo sobre a conciliação 20 (só leitura): 8 vínculos desse tipo, todos com diferença zero (BRASPRESS 234,40 = 4 e 616,82 = 7 títulos; TEX COURIER 468,74 = 11; SUD LOG, JOICEMAR, LEOMAR, SEARA, F N BERLANDA). Vale para novas importações. Critério explicado no modal "Vínculo #N".
|
||||
|
||||
@ -32,8 +32,9 @@ Função pura `concilia(itens, tolerancia) -> list[VinculoSugerido]`, uma conta
|
||||
1. **Segmentação por saldo zerado**: cada ponto em que o saldo corrente (na ordem do arquivo) volta a zero fecha um segmento, e vínculo nunca atravessa segmento. Isso evita casar o frete de janeiro com um pagamento de mesmo valor de julho.
|
||||
2. **1:1 por valor exato**, menor distância de datas.
|
||||
3. **Combinação exata** (vários débitos = um crédito, ou vários créditos, como NF + juros, = um débito): até 6 itens, janela de 120 dias, **só os 12 candidatos mais próximos em data**, teto de 20 mil nós por busca. O limite de candidatos existe porque, numa conta com centenas de lançamentos pequenos (EBAZAR, 456 no arquivo real), uma soma exata entre itens sem relação é quase certa se todos puderem participar.
|
||||
4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`.
|
||||
5. **Tolerância**, só no último segmento (o que está em aberto): 1:1 ou 2 itens com diferença até o limite. Com 3 ou mais itens, combinações "a menos de R$ 10" aparecem por acaso.
|
||||
4. **Quitação dos títulos mais antigos** (`_quita_mais_antigos`, `tipo="sequencia"`, desde 2026-09-25): cada pagamento pendente, em ordem de data, contra a sequência dos títulos mais antigos em aberto (data até a do pagamento); vira vínculo só se a soma fechar **exatamente**, com 2 títulos ou mais. Sem limite de itens nem de candidatos, porque há uma única sequência possível (não é subset-sum). Motivo: fretes acumulados que a combinação não achava por estarem fora dos 12 mais próximos (BRASPRESS: R$ 234,40 = 4 títulos; R$ 616,82 = 7; TEX COURIER: R$ 468,74 = 11). Roda em todo segmento, inclusive nos fechados, antes da compensação.
|
||||
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou vira um vínculo `saldo_zerado`.
|
||||
5. ~~Tolerância~~ **removida em 2026-09-25** (pedido do usuário: "no caso de juros, não vincular automaticamente, mas sugerir e apontar a possibilidade de juros"). Era 1:1 ou 2 itens com diferença até o limite, só no último segmento. Hoje esse par fica pendente e vira **sugestão** em `alertas.py` (ver "valor próximo" abaixo). `TIPO_TOLERANCIA` continua existindo pelos vínculos gravados antes (21 numa conciliação, na data da mudança); `concilia()` não recebe mais `tolerancia`.
|
||||
|
||||
Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensações, 21 com diferença.
|
||||
|
||||
@ -43,7 +44,7 @@ Resultado no arquivo real: 532 vínculos exatos, 99 combinações, 20 compensaç
|
||||
|
||||
**Fato x sugestão: só vínculo muda valor** (pedido do usuário). Um título sem vínculo está em aberto pelo **valor cheio**; um pagamento sem vínculo é "Pagamento não vinculado". O razão não traz parcelamento, vencimento nem NF no pagamento, então qualquer dedução sobre qual título um pagamento paga é **sugestão**: vai em `SituacaoItem.sugestao` (texto à parte), nunca na `descricao` (só fato) nem em `valor_em_aberto`. Na tela, a sugestão aparece numa linha própria abaixo do fato, com a etiqueta "Sugestão", em itálico e cor neutra; no XLSX, depois de "Sugestão:". O contador confirma criando o vínculo manual (inclusive o de pagamento parcial, que aí sim reduz o valor em aberto).
|
||||
|
||||
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: primeiro o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela), senão o título mais antigo com saldo. Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável.
|
||||
As sugestões saem de uma **distribuição** dos pagamentos não vinculados sobre os títulos em aberto: antes de tudo, numa passada própria anterior à ordem por data, os **pares de mesmo valor** (título × pagamento, desempate igual ao `_pareia_exato` do motor: data mais próxima, depois pagamento após o título; sugestão "Mesmo valor do título de ..."); em seguida, também em passada própria, os **pares de valor próximo** dentro da tolerância da conciliação (`analisa_conta(..., tolerancia=)`, a mesma do cabeçalho: R$ 10 ou 2%, o maior), 1:1, menor diferença e depois data mais próxima, com a sugestão "Possível pagamento do título de ..., com R$ 6,20 a menos (possível desconto)" / "a mais (possível juros/multa)" dos dois lados; depois o título do qual o pagamento parece ser **parcela** (título ≈ k × pagamento, k de 2 a 24, até 1 centavo por parcela; se nenhum título bater assim, aceita o pagamento a até `TOLERANCIA_PERCENTUAL_PADRAO`% (2%) da parcela arredondada mais próxima, só o percentual, sem o piso fixo de R$ 10, e a sugestão diz a diferença; a sugestão sempre diz a quantidade de parcelas: "em 3x de R$ 552,27" / "em 4x de R$ 2.163,56, com R$ 0,01 a mais (juros/desconto?)"; ver `_parcela()` em `alertas.py`), senão o título mais antigo com saldo (sugestão fraca, e o texto diz isso: "Sem título de valor correspondente; o(s) mais antigo(s) em aberto: ..."; com mais de um título, acrescenta "Soma: R$ X; saldo residual de R$ Y em aberto." ou "...; o pagamento excede em R$ Y.", sendo a soma do **saldo ainda disponível** de cada título na distribuição, não do valor cheio). Ela serve só para escrever as sugestões e para achar o **excedente** (pagamentos não vinculados além de todos os títulos em aberto), que é fato e é a divergência. Motivo de existir: o arquivo real tem muitos parcelamentos (NF 1.299,00 em 3 × 433,00; NF 25.325,00 em 5 × 5.065,00), e a sugestão aponta ao contador o título provável.
|
||||
|
||||
| Categoria | Significado (fato) | Alerta |
|
||||
|---|---|---|
|
||||
@ -62,7 +63,7 @@ Status: `divergente` (algum alerta alto) > `atencao` (médio/baixo) > `pendente`
|
||||
|
||||
## Models (`portal_api/models.py`)
|
||||
|
||||
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente).
|
||||
`ConciliacaoFornecedor` (cabeçalho: empresa, data-base, período, arquivo, tolerâncias, `resumo` JSON) → `ConciliacaoFornecedorConta` (saldos, `validada`/`validada_por`/`validada_em`, `observacao`) → `ConciliacaoFornecedorVinculo` (`tipo` exato/combinacao/saldo_zerado/tolerancia/manual/**parcial**, `diferenca` = créditos − débitos, `criado_por` só no manual) e `ConciliacaoFornecedorLancamento` (`vinculo` FK `SET_NULL`: excluir o vínculo devolve os lançamentos para pendente). `ConciliacaoFornecedorVinculoHistorico` (por conta): cada ação manual sobre vínculo (`acao` vinculou/acrescentou/desfez, este último inclusive para vínculo automático), com `usuario`, `criado_em`, `tipo_rotulo`/`vinculo_automatico` e **uma cópia dos lançamentos envolvidos** em `lancamentos` (JSON com seq., data, natureza, valor e `acrescentado`), porque o vínculo desfeito é apagado. Gravado por `_conciliacao_registra_historico()` dentro da mesma transação da ação. Vínculos criados pelo motor na importação **não** entram (não são ação de ninguém); ações anteriores à rodada 23 não têm registro.
|
||||
|
||||
## API
|
||||
|
||||
@ -76,6 +77,7 @@ Todas com `PermissaoApp("utilitarios", "conciliacao-fornecedores")`.
|
||||
| `/api/conciliacoes-fornecedores/{id}/exportar-xlsx/` | GET | planilha com abas Resumo, Pendências e Alertas |
|
||||
| `/api/conciliacoes-fornecedores-contas/{id}/vincular/` | POST | `{lancamentos: [ids]}`: vínculo manual; exige itens sem vínculo, da própria conta, com pelo menos um débito e um crédito. Créditos maiores que débitos **além da tolerância** da conciliação viram `tipo="parcial"` (pagamento parcial: a `diferenca` continua em aberto, entra na distribuição como um título com a data do crédito mais antigo do vínculo e conta para o aging); dentro da tolerância, ou pago a maior, `tipo="manual"` (fecha, com alerta de diferença) |
|
||||
| `/api/conciliacoes-fornecedores-contas/{id}/validar/` | POST | `{validada, observacao}` |
|
||||
| `/api/conciliacoes-fornecedores-contas/{id}/historico-vinculos/` | GET | histórico das ações manuais sobre vínculos da conta, mais recente primeiro (botão "i" do razão) |
|
||||
| `/api/conciliacoes-fornecedores-vinculos/{id}/` | DELETE | desfaz o vínculo |
|
||||
| `/api/conciliacoes-fornecedores-vinculos/{id}/adicionar/` | POST | `{lancamentos: [ids]}` (1 ou mais): acrescenta lançamentos pendentes da mesma conta a um vínculo **manual ou parcial** (automático é 400), sem desfazer. Tipo e diferença recalculados por `_conciliacao_tipo_vinculo_manual()`, a mesma regra da criação (pode continuar parcial, fechar ou virar pago a maior); `criado_por` passa a ser quem acrescentou |
|
||||
|
||||
@ -88,9 +90,13 @@ Duas vistas na mesma página. **Histórico**: formulário de nova conciliação
|
||||
- **uma faixa de indicadores**: saldo em aberto, em aberto há mais de 3 meses (clica e filtra), pagamentos sem título (divergência, clica e filtra) e uma **barra de progresso da validação** ("146 de 260");
|
||||
- os apontamentos menores (diferença de juros/desconto, pagamento sem título de mesmo valor, conta transitória) como **etiquetas** clicáveis numa linha, só quando existem;
|
||||
- **os números por situação ficam só nos chips** (Todos/Divergentes/Atenção/Pendentes/Conciliados/Validados), que também filtram: não duplicar em card;
|
||||
- **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar;
|
||||
- **contas validadas escondidas por padrão** ("Mostrar validadas" ao lado da busca; o chip "Validados" mostra sempre), para a lista mostrar o que falta revisar; depois de qualquer ajuste numa conta (validar, vincular, desfazer), `aplicarRespostaConta()` reaplica o filtro (`contaPassaFiltro()`, a mesma regra de `contasFiltradas()`) e, se a conta não couber mais, ela sai na hora com um fade (`removerConta()`), sem esperar o próximo render da lista;
|
||||
- filtros e cabeçalho da tabela **fixos ao rolar** (`position: sticky` abaixo da `.topbar`; `.conc-table-wrap` desliga o `overflow:hidden` de `.pa-table-wrap`, que anularia o sticky);
|
||||
- linhas compactas, com o fornecedor e o primeiro alerta cortados com reticências e a situação só como etiqueta.
|
||||
- nas sub-tabelas de lançamentos, **histórico, contrapartida e sugestão ficam em uma linha cada**, cortados com reticências e com o texto completo num tooltip no visual do Portal (`[data-conc-tooltip]` + `.conc-tooltip`, único elemento `fixed` no body, só aparece quando o texto está cortado; **nunca `title` nativo**, pedido do usuário) (`pidConcHistoricoHtml()`/`pidConcSituacaoHtml()`; `max-width: 0` + largura em % nas colunas Histórico/Situação), para a altura da linha não variar com a largura da tela.
|
||||
- **"i" pequeno em cada linha vinculada** do razão (ao lado de "Desfazer"): abre o detalhe do vínculo (`pidConcAbreDetalheVinculo()`, só frontend, com o payload já carregado): tipo, automático/manual, o **critério** que o motor usou (`PID_CONC_CRITERIO_VINCULO`, texto por tipo; se mudar uma regra do `motor.py`, atualizar o texto), os lançamentos com histórico/contrapartida e os totais de créditos/débitos com a diferença. Mesmo modal do histórico (`pidConcAbreModalInfo()`).
|
||||
- botão **"i"** no canto direito da barra do razão abre o modal "Histórico de vinculações" da conta (`pidConcAbreHistoricoVinculos()`, criado sob demanda no body): cada ação com quem, quando, tipo e os seq. envolvidos, com histórico e contrapartida de cada um (lidos na hora pelo id no endpoint, não gravados na cópia) e os acrescentados marcados, borda verde/roxa/vermelha para vinculou/acrescentou/desfez.
|
||||
- passar o mouse na **sugestão** destaca em dourado a própria linha e as do outro lado da sugestão (`situacao.relacionados` no payload, id negativo = saldo de vínculo parcial; classe `is-sugestao-grupo`), no mesmo espírito do destaque verde do vínculo. **Duplo clique na sugestão** marca a linha e as relacionadas não vinculadas (pelo `change` dos checkboxes, então soma e "Vincular selecionados" acompanham); se já estavam todas marcadas, desmarca. Por isso o clique simples dentro da sugestão não marca/desmarca a linha (dispararia duas vezes antes do duplo clique).
|
||||
- na conta expandida, a **barra de seleção** (soma de créditos/débitos, diferença e "Vincular selecionados") e o thead da lista de pendentes também ficam fixos, empilhados abaixo do thead da tabela de fornecedores (`--conc-thead-altura`/`--conc-selecao-altura` no `#conc-detail-view`; se a altura de um deles mudar, ajustar a variável). A `.conc-subtabela--pendentes` desliga o `overflow:hidden` pelo mesmo motivo de `.conc-table-wrap`.
|
||||
|
||||
A tabela de fornecedores, **ordenada por padrão pela situação, divergentes primeiro** (depois atenção, pendente, conciliada) (conta, fornecedor, saldo inicial, saldo residual, em aberto há mais de 3 meses, situação com ícone, e um **checkbox "Validado" no fim da linha**, pedido do usuário). O checkbox grava direto pelo mesmo `validar/` do botão "Marcar como validada" do detalhe, sem enviar `observacao` (o servidor mantém a já registrada), e o clique nele não expande a linha. **Quando a conta passa a validada** (por qualquer caminho, inclusive a validação automática), a linha expandida recolhe. Clicar numa linha expande: alertas, o **razão desde a primeira pendência** (todos os lançamentos, na ordem do arquivo e com a coluna de saldo do Questor, a partir da data do lançamento pendente mais antigo, incluindo os itens de pagamento parcial; os já vinculados aparecem apagados como "Vinculado #N" com o tipo, sem seleção, destacando o vínculo inteiro ao passar o mouse e com "Desfazer" na própria linha, para achar vínculo automático errado; um toggle **"Ocultar conciliados"** ao lado do título, **desmarcado por padrão** (pedido do usuário), esconde essas linhas vinculadas só por CSS, e a escolha fica em `estadoDe(conta.id).ocultarConciliados`, sobrevivendo ao re-render depois de vincular/desfazer), os **lançamentos não conciliados** (com checkbox para vínculo manual, valor em aberto e situação de cada um), lançamentos conciliados agrupados por vínculo (ocultos por padrão, com "Desfazer vínculo") e observação/validação da conta.
|
||||
|
||||
@ -6,9 +6,11 @@ porque o contador pode desfazer ou criar vínculos a qualquer momento.
|
||||
título sem vínculo está em aberto pelo **valor cheio**, e um pagamento sem
|
||||
vínculo é "pagamento não vinculado", mesmo que a ferramenta desconfie de
|
||||
qual título ele paga. Essa desconfiança sai numa distribuição dos
|
||||
pagamentos não vinculados sobre os títulos em aberto (primeiro o título do
|
||||
pagamentos não vinculados sobre os títulos em aberto (antes de tudo, os
|
||||
pares de mesmo valor, com o desempate do motor; depois o título do
|
||||
qual o pagamento parece ser parcela, título ≈ k × pagamento com k de 2 a
|
||||
`MAX_PARCELAS`; senão o mais antigo com saldo), usada **só** para:
|
||||
`MAX_PARCELAS`, primeiro a de arredondamento exato e depois a com pequena
|
||||
diferença, ver `_parcela`; senão o mais antigo com saldo), usada **só** para:
|
||||
|
||||
- escrever a sugestão de cada item (`SituacaoItem.sugestao`, texto à parte
|
||||
da `descricao`, que é só fato);
|
||||
@ -28,9 +30,9 @@ from __future__ import annotations
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
|
||||
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA
|
||||
from .motor import TIPO_MANUAL, TIPO_PARCIAL, TIPO_TOLERANCIA, TOLERANCIA_PERCENTUAL_PADRAO, Tolerancia
|
||||
|
||||
MESES_VENCIDO = 3
|
||||
MAX_PARCELAS = 24
|
||||
@ -126,22 +128,45 @@ def _soma_meses(data: date, meses: int) -> date:
|
||||
return date(ano, mes, min(data.day, dias_no_mes[mes - 1]))
|
||||
|
||||
|
||||
def _eh_parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> bool:
|
||||
def _parcela(valor_titulo: Decimal, valor_pagamento: Decimal) -> tuple[int, Decimal, Decimal] | None:
|
||||
"""Se o pagamento parece parcela do título (título ≈ k × pagamento, k de
|
||||
2 a `MAX_PARCELAS`), devolve (k, parcela de referência, diferença do
|
||||
pagamento para ela); senão None. `k` vai no texto da sugestão ("em 3x").
|
||||
|
||||
Diferença zero: bate com o arredondamento da divisão (até 1 centavo por
|
||||
parcela no total). Diferença não zero: o pagamento fica a até
|
||||
`TOLERANCIA_PERCENTUAL_PADRAO`% da parcela arredondada mais próxima
|
||||
(possível juros/desconto), e a sugestão mostra quanto (pedido do usuário:
|
||||
um pagamento de R$ 2.163,57 de um título de R$ 8.654,22 em 4 parcelas
|
||||
não era reconhecido como parcela). Só o percentual, sem o piso fixo de
|
||||
`TOLERANCIA_VALOR_PADRAO`, que em parcela pequena aceitaria quase tudo.
|
||||
"""
|
||||
if valor_pagamento <= 0 or valor_pagamento >= valor_titulo:
|
||||
return False
|
||||
return None
|
||||
k = round(valor_titulo / valor_pagamento)
|
||||
if k < 2 or k > MAX_PARCELAS:
|
||||
return False
|
||||
# arredondamento de parcela: até 1 centavo por parcela
|
||||
return abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k
|
||||
return None
|
||||
if abs(valor_titulo - valor_pagamento * k) <= Decimal("0.01") * k:
|
||||
return k, valor_pagamento, Decimal("0")
|
||||
parcela_baixa = (valor_titulo / k).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
|
||||
referencia = min((parcela_baixa, parcela_baixa + Decimal("0.01")), key=lambda p: abs(valor_pagamento - p))
|
||||
diferenca = valor_pagamento - referencia
|
||||
if abs(diferenca) <= referencia * TOLERANCIA_PERCENTUAL_PADRAO / Decimal(100):
|
||||
return k, referencia, diferenca
|
||||
return None
|
||||
|
||||
|
||||
def _rotulo_titulo(credito: ItemAnalise) -> str:
|
||||
return f"{credito.data:%d/%m/%Y} ({_moeda(credito.valor)})"
|
||||
|
||||
|
||||
def _natureza_diferenca(diferenca: Decimal) -> str:
|
||||
"""`diferenca` = título - pagamento (mesma convenção do vínculo)."""
|
||||
return "a menos (possível desconto)" if diferenca > 0 else "a mais (possível juros/multa)"
|
||||
|
||||
|
||||
def _analisa_pendentes(
|
||||
itens: list[ItemAnalise], data_base: date
|
||||
itens: list[ItemAnalise], data_base: date, tolerancia: Tolerancia | None = None
|
||||
) -> tuple[dict[int, SituacaoItem], Decimal, Decimal, Decimal, Decimal]:
|
||||
creditos = sorted((i for i in itens if i.natureza == "C"), key=lambda i: (i.data, i.id))
|
||||
debitos = sorted((i for i in itens if i.natureza == "D"), key=lambda i: (i.data, i.id))
|
||||
@ -154,19 +179,102 @@ def _analisa_pendentes(
|
||||
total_nao_vinculado = Decimal("0")
|
||||
total_excedente = Decimal("0")
|
||||
|
||||
# 1º: pagamento de mesmo valor de um título, antes de tudo e antes de
|
||||
# percorrer os pagamentos por data (senão um pagamento anterior poderia
|
||||
# abater, pelo "mais antigo com saldo", o título do par exato de outro).
|
||||
# Mesmo desempate do motor (`_pareia_exato`): data mais próxima, depois
|
||||
# pagamento após o título antes de pagamento adiantado. Pedido do usuário:
|
||||
# ao desfazer um vínculo automático exato, a sugestão apontava outro título.
|
||||
candidatos_exatos = sorted(
|
||||
(
|
||||
(abs((c.data - d.data).days), 1 if d.data < c.data else 0, c.data, c.id, d.id, c, d)
|
||||
for c in creditos
|
||||
for d in debitos
|
||||
if c.valor == d.valor
|
||||
),
|
||||
key=lambda t: t[:5],
|
||||
)
|
||||
par_exato: dict[int, ItemAnalise] = {} # id do débito -> título
|
||||
creditos_pareados: set[int] = set()
|
||||
for *_, credito, debito in candidatos_exatos:
|
||||
if debito.id in par_exato or credito.id in creditos_pareados:
|
||||
continue
|
||||
par_exato[debito.id] = credito
|
||||
creditos_pareados.add(credito.id)
|
||||
saldo_sugerido[credito.id] = Decimal("0")
|
||||
pagos_sugeridos[credito.id].append(debito.id)
|
||||
|
||||
# 2º: valor próximo, dentro da tolerância da conciliação (possível
|
||||
# juros/desconto). Antes era vínculo automático do motor; o usuário pediu
|
||||
# que vire só sugestão, apontando a possibilidade de juros/desconto. 1:1,
|
||||
# menor diferença primeiro, depois data mais próxima.
|
||||
par_proximo: dict[int, ItemAnalise] = {} # id do débito -> título
|
||||
if tolerancia:
|
||||
candidatos_proximos = sorted(
|
||||
(
|
||||
(abs(c.valor - d.valor), abs((c.data - d.data).days), c.id, d.id, c, d)
|
||||
for c in creditos
|
||||
if c.id not in creditos_pareados
|
||||
for d in debitos
|
||||
if d.id not in par_exato
|
||||
and abs(c.valor - d.valor) * 100 <= tolerancia.limite_centavos(int(c.valor * 100))
|
||||
),
|
||||
key=lambda t: t[:4],
|
||||
)
|
||||
for *_, credito, debito in candidatos_proximos:
|
||||
if debito.id in par_proximo or credito.id in creditos_pareados:
|
||||
continue
|
||||
par_proximo[debito.id] = credito
|
||||
creditos_pareados.add(credito.id)
|
||||
saldo_sugerido[credito.id] = Decimal("0")
|
||||
pagos_sugeridos[credito.id].append(debito.id)
|
||||
titulo_proximo = {credito.id: debito_id for debito_id, credito in par_proximo.items()}
|
||||
por_id_debito = {d.id: d for d in debitos}
|
||||
|
||||
for debito in debitos:
|
||||
if debito.id in par_proximo:
|
||||
credito = par_proximo[debito.id]
|
||||
diferenca = credito.valor - debito.valor
|
||||
total_nao_vinculado += debito.valor
|
||||
situacoes[debito.id] = SituacaoItem(
|
||||
CATEGORIA_NAO_VINCULADO,
|
||||
Decimal("0"),
|
||||
"Pagamento não vinculado.",
|
||||
f"Possível pagamento do título de {_rotulo_titulo(credito)}, com {_moeda(diferenca)} "
|
||||
f"{_natureza_diferenca(diferenca)}. Vincule para confirmar.",
|
||||
[credito.id],
|
||||
)
|
||||
continue
|
||||
if debito.id in par_exato:
|
||||
credito = par_exato[debito.id]
|
||||
total_nao_vinculado += debito.valor
|
||||
situacoes[debito.id] = SituacaoItem(
|
||||
CATEGORIA_NAO_VINCULADO,
|
||||
Decimal("0"),
|
||||
"Pagamento não vinculado.",
|
||||
f"Mesmo valor do título de {_rotulo_titulo(credito)}. Vincule para confirmar.",
|
||||
[credito.id],
|
||||
)
|
||||
continue
|
||||
|
||||
restante = debito.valor
|
||||
usados: list[int] = []
|
||||
parcela_de = next(
|
||||
(c for c in creditos if saldo_sugerido[c.id] >= debito.valor and _eh_parcela(c.valor, debito.valor)),
|
||||
None,
|
||||
)
|
||||
# Parcela exata (arredondamento) antes da com diferença, em qualquer título.
|
||||
candidatas = [
|
||||
(c, parcela)
|
||||
for c in creditos
|
||||
if saldo_sugerido[c.id] >= debito.valor and (parcela := _parcela(c.valor, debito.valor))
|
||||
]
|
||||
escolhida = next((p for p in candidatas if p[1][2] == 0), candidatas[0] if candidatas else None)
|
||||
parcela_de = escolhida[0] if escolhida else None
|
||||
ordem = ([parcela_de] if parcela_de else []) + [c for c in creditos if c is not parcela_de]
|
||||
disponivel_usado = Decimal("0") # saldo dos títulos usados antes deste pagamento
|
||||
for credito in ordem:
|
||||
if restante <= 0:
|
||||
break
|
||||
if saldo_sugerido[credito.id] <= 0:
|
||||
continue
|
||||
disponivel_usado += saldo_sugerido[credito.id]
|
||||
abatido = min(restante, saldo_sugerido[credito.id])
|
||||
saldo_sugerido[credito.id] -= abatido
|
||||
restante -= abatido
|
||||
@ -174,10 +282,36 @@ def _analisa_pendentes(
|
||||
pagos_sugeridos[credito.id].append(debito.id)
|
||||
|
||||
titulos = [c for c in creditos if c.id in usados]
|
||||
if parcela_de and usados == [parcela_de.id]:
|
||||
sugestao = f"Possível parcela do título de {_rotulo_titulo(parcela_de)}. Vincule para confirmar."
|
||||
if escolhida and usados == [parcela_de.id]:
|
||||
qtd_parcelas, referencia, diferenca = escolhida[1]
|
||||
# Quantidade de parcelas no texto (pedido do usuário).
|
||||
if diferenca == 0:
|
||||
sugestao = (
|
||||
f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de "
|
||||
f"{_moeda(debito.valor)}. Vincule para confirmar."
|
||||
)
|
||||
else:
|
||||
sentido = "a mais" if diferenca > 0 else "a menos"
|
||||
sugestao = (
|
||||
f"Possível parcela do título de {_rotulo_titulo(parcela_de)}, em {qtd_parcelas}x de "
|
||||
f"{_moeda(referencia)}, com {_moeda(diferenca)} {sentido} (juros/desconto?). Vincule para confirmar."
|
||||
)
|
||||
elif titulos:
|
||||
sugestao = f"Pode ser pagamento do(s) título(s) de {', '.join(_rotulo_titulo(c) for c in titulos)}."
|
||||
# Sem par de valor nem parcela: só a ordem de antiguidade, sugestão
|
||||
# fraca, e o texto deixa isso claro (pedido do usuário).
|
||||
sugestao = (
|
||||
"Sem título de valor correspondente; o(s) mais antigo(s) em aberto: "
|
||||
f"{', '.join(_rotulo_titulo(c) for c in titulos)}."
|
||||
)
|
||||
# Vários títulos: soma e o que sobra (pedido do usuário). A soma é
|
||||
# do saldo ainda disponível de cada um, que pode ser menor que o
|
||||
# valor do título se um pagamento anterior já o abateu em parte.
|
||||
if len(titulos) > 1:
|
||||
if restante > 0:
|
||||
residual = f"o pagamento excede em {_moeda(restante)}"
|
||||
else:
|
||||
residual = f"saldo residual de {_moeda(disponivel_usado - debito.valor)} em aberto"
|
||||
sugestao += f" Soma: {_moeda(disponivel_usado)}; {residual}."
|
||||
else:
|
||||
sugestao = ""
|
||||
|
||||
@ -202,6 +336,13 @@ def _analisa_pendentes(
|
||||
coberto = credito.valor - saldo_sugerido[credito.id]
|
||||
if not pagos:
|
||||
sugestao = ""
|
||||
elif credito.id in titulo_proximo:
|
||||
debito = por_id_debito[titulo_proximo[credito.id]]
|
||||
diferenca = credito.valor - debito.valor
|
||||
sugestao = (
|
||||
f"Possível pagamento em {debito.data:%d/%m/%Y} ({_moeda(debito.valor)}), com {_moeda(diferenca)} "
|
||||
f"{_natureza_diferenca(diferenca)}. Vincule para confirmar."
|
||||
)
|
||||
elif saldo_sugerido[credito.id] <= 0:
|
||||
sugestao = f"Pode ter sido quitado por {len(pagos)} pagamento(s) não vinculado(s). Vincule para confirmar."
|
||||
else:
|
||||
@ -226,9 +367,11 @@ def analisa_conta(
|
||||
saldo_final: Decimal,
|
||||
saldo_final_arquivo: Decimal,
|
||||
data_base: date,
|
||||
tolerancia: Tolerancia | None = None,
|
||||
) -> AnaliseConta:
|
||||
"""`saldo_final` é o recalculado (saldo anterior + créditos - débitos);
|
||||
`saldo_final_arquivo` o último impresso pelo Questor. Credor positivo."""
|
||||
`saldo_final_arquivo` o último impresso pelo Questor. Credor positivo.
|
||||
`tolerancia` (a da conciliação) só alimenta a sugestão de valor próximo."""
|
||||
pendentes = [i for i in itens if i.vinculo_id is None]
|
||||
# O saldo de cada pagamento parcial vinculado à mão continua em aberto:
|
||||
# entra como um título a mais (id negativo = -id do vínculo), com a data
|
||||
@ -246,7 +389,7 @@ def analisa_conta(
|
||||
)
|
||||
)
|
||||
situacoes, total_aberto, total_vencido, total_nao_vinculado, total_excedente = _analisa_pendentes(
|
||||
pendentes, data_base
|
||||
pendentes, data_base, tolerancia
|
||||
)
|
||||
alertas: list[AlertaConta] = []
|
||||
|
||||
|
||||
@ -18,12 +18,23 @@ ordem:
|
||||
créditos, como NF + juros, somando um débito), limitada a
|
||||
`MAX_ITENS_COMBINACAO` itens numa janela de `JANELA_DIAS_COMBINACAO`
|
||||
dias, com teto de nós visitados por busca (subset-sum é exponencial).
|
||||
4. **Compensação do segmento fechado**: o que sobrou num segmento que zerou
|
||||
4. **Quitação dos títulos mais antigos**: para cada pagamento ainda
|
||||
pendente, em ordem de data, se os títulos mais antigos em aberto (com
|
||||
data até a do pagamento), somados em sequência, fecham **exatamente** o
|
||||
pagamento, viram um vínculo. Não é um subconjunto qualquer (há uma única
|
||||
sequência possível), então não tem o limite de itens nem de candidatos
|
||||
da combinação. Pedido do usuário (2026-09-25): pagamentos de fretes
|
||||
acumulados (BRASPRESS: R$ 234,40 = 4 títulos, R$ 616,82 = 7 títulos) que
|
||||
a combinação não achava, por os títulos estarem fora dos 12 mais
|
||||
próximos em data.
|
||||
5. **Compensação do segmento fechado**: o que sobrou num segmento que zerou
|
||||
o saldo se compensa por definição; vira um único vínculo.
|
||||
5. **Tolerância** (só no último segmento, o que ficou em aberto): 1:1 ou
|
||||
combinação pequena com diferença até o limite configurado (valor fixo
|
||||
**ou** percentual sobre o valor do título, o que for maior), sinalizada
|
||||
como possível juros/desconto.
|
||||
A diferença dentro da tolerância (possível juros/desconto) **não** vira
|
||||
vínculo automático (pedido do usuário, 2026-09-25: antes era a etapa 5, 1:1
|
||||
ou 2 itens no último segmento): o par fica pendente e `alertas.py` o aponta
|
||||
como sugestão, para o contador confirmar com um vínculo manual.
|
||||
`TIPO_TOLERANCIA`/`_combina(..., tolerancia)` continuam existindo pelos
|
||||
vínculos gravados antes disso.
|
||||
|
||||
O que sobrar fica pendente, sem vínculo gravado. `alertas.py` distribui os
|
||||
débitos pendentes sobre os créditos pendentes (parcelas, pagamento parcial)
|
||||
@ -55,6 +66,7 @@ TOLERANCIA_PERCENTUAL_PADRAO = Decimal("2.00")
|
||||
|
||||
TIPO_EXATO = "exato"
|
||||
TIPO_COMBINACAO = "combinacao"
|
||||
TIPO_SEQUENCIA = "sequencia" # quitação dos títulos mais antigos (etapa 4)
|
||||
TIPO_SALDO_ZERADO = "saldo_zerado"
|
||||
TIPO_TOLERANCIA = "tolerancia"
|
||||
TIPO_MANUAL = "manual"
|
||||
@ -247,12 +259,35 @@ def _combina(
|
||||
return vinculos
|
||||
|
||||
|
||||
def _quita_mais_antigos(pendentes: list[ItemConciliacao]) -> list[VinculoSugerido]:
|
||||
"""Etapa 4 (ver topo do módulo): cada pagamento, em ordem de data, contra
|
||||
a sequência dos títulos mais antigos ainda em aberto, só se a soma fechar
|
||||
exatamente (2 títulos ou mais; 1:1 já é da etapa de valor exato)."""
|
||||
creditos = sorted((i for i in pendentes if i.natureza == "C"), key=lambda i: (i.data, i.indice))
|
||||
debitos = sorted((i for i in pendentes if i.natureza == "D"), key=lambda i: (i.data, i.indice))
|
||||
vinculos: list[VinculoSugerido] = []
|
||||
for debito in debitos:
|
||||
soma = 0
|
||||
grupo: list[ItemConciliacao] = []
|
||||
for credito in creditos:
|
||||
if credito.data > debito.data or soma >= debito.centavos:
|
||||
break
|
||||
soma += credito.centavos
|
||||
grupo.append(credito)
|
||||
if soma != debito.centavos or len(grupo) < 2:
|
||||
continue
|
||||
vinculos.append(_vinculo(TIPO_SEQUENCIA, [*grupo, debito]))
|
||||
usados = {c.indice for c in grupo}
|
||||
creditos = [c for c in creditos if c.indice not in usados]
|
||||
return vinculos
|
||||
|
||||
|
||||
def _sem_vinculo(itens: list[ItemConciliacao], vinculos: list[VinculoSugerido]) -> list[ItemConciliacao]:
|
||||
usados = {indice for v in vinculos for indice in v.indices}
|
||||
return [i for i in itens if i.indice not in usados]
|
||||
|
||||
|
||||
def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[VinculoSugerido]:
|
||||
def concilia(itens: list[ItemConciliacao]) -> list[VinculoSugerido]:
|
||||
"""Roda as etapas descritas no topo do módulo para uma conta. `itens`
|
||||
precisa estar na ordem do arquivo (é ela que define o saldo corrente)."""
|
||||
vinculos: list[VinculoSugerido] = []
|
||||
@ -263,11 +298,9 @@ def concilia(itens: list[ItemConciliacao], tolerancia: Tolerancia) -> list[Vincu
|
||||
aberto = posicao == len(segmentos) - 1
|
||||
locais = _pareia_exato(segmento)
|
||||
locais += _combina(_sem_vinculo(segmento, locais), TIPO_COMBINACAO, MAX_ITENS_COMBINACAO, None)
|
||||
locais += _quita_mais_antigos(_sem_vinculo(segmento, locais))
|
||||
restantes = _sem_vinculo(segmento, locais)
|
||||
if not aberto:
|
||||
if restantes:
|
||||
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
|
||||
else:
|
||||
locais += _combina(restantes, TIPO_TOLERANCIA, MAX_ITENS_TOLERANCIA, tolerancia)
|
||||
if not aberto and restantes:
|
||||
locais.append(_vinculo(TIPO_SALDO_ZERADO, restantes))
|
||||
vinculos += locais
|
||||
return vinculos
|
||||
|
||||
34
portal_api/migrations/0084_conciliacao_historico_vinculos.py
Normal file
34
portal_api/migrations/0084_conciliacao_historico_vinculos.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
portal_api/migrations/0085_conciliacao_vinculo_sequencia.py
Normal file
18
portal_api/migrations/0085_conciliacao_vinculo_sequencia.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -2769,6 +2769,7 @@ class ConciliacaoFornecedorVinculo(models.Model):
|
||||
TIPO_CHOICES = [
|
||||
("exato", "Valor exato"),
|
||||
("combinacao", "Combinação de valores"),
|
||||
("sequencia", "Quitação dos títulos mais antigos"),
|
||||
("saldo_zerado", "Compensação até saldo zerado"),
|
||||
("tolerancia", "Com diferença (juros/desconto)"),
|
||||
("manual", "Manual"),
|
||||
@ -2822,3 +2823,39 @@ class ConciliacaoFornecedorLancamento(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.data:%d/%m/%Y} {self.natureza} {self.valor}"
|
||||
|
||||
|
||||
class ConciliacaoFornecedorVinculoHistorico(models.Model):
|
||||
"""Registro de cada ação manual sobre vínculos de uma conta (vincular,
|
||||
acrescentar lançamentos, desfazer — inclusive o desfazer de um vínculo
|
||||
automático), para o contador saber quem vinculou/desvinculou o quê
|
||||
(pedido do usuário). `lancamentos` é uma cópia dos lançamentos
|
||||
envolvidos no momento da ação (seq., data, natureza, valor), porque o
|
||||
vínculo desfeito é apagado e `vinculo_id` deixa de existir."""
|
||||
|
||||
ACAO_VINCULOU = "vinculou"
|
||||
ACAO_ACRESCENTOU = "acrescentou"
|
||||
ACAO_DESFEZ = "desfez"
|
||||
ACAO_CHOICES = [
|
||||
(ACAO_VINCULOU, "Vinculou"),
|
||||
(ACAO_ACRESCENTOU, "Acrescentou ao vínculo"),
|
||||
(ACAO_DESFEZ, "Desfez o vínculo"),
|
||||
]
|
||||
|
||||
conta = models.ForeignKey(ConciliacaoFornecedorConta, on_delete=models.CASCADE, related_name="historico_vinculos")
|
||||
acao = models.CharField("Ação", max_length=20, choices=ACAO_CHOICES)
|
||||
vinculo_id_original = models.IntegerField("Vínculo (id)", null=True, blank=True)
|
||||
tipo_rotulo = models.CharField("Tipo do vínculo", max_length=60, blank=True)
|
||||
vinculo_automatico = models.BooleanField("Vínculo automático", default=False)
|
||||
# [{"id", "sequencia", "data", "natureza", "valor", "eh_saldo_anterior", "acrescentado"}]
|
||||
lancamentos = models.JSONField("Lançamentos envolvidos", default=list)
|
||||
usuario = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
||||
criado_em = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Histórico de vínculo (Conciliação de fornecedores)"
|
||||
verbose_name_plural = "Histórico de vínculos (Conciliação de fornecedores)"
|
||||
ordering = ["-criado_em", "-id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.get_acao_display()} #{self.vinculo_id_original} ({self.criado_em:%d/%m/%Y %H:%M})"
|
||||
|
||||
@ -65,6 +65,7 @@ from .models import (
|
||||
ConciliacaoFornecedorConta,
|
||||
ConciliacaoFornecedorLancamento,
|
||||
ConciliacaoFornecedorVinculo,
|
||||
ConciliacaoFornecedorVinculoHistorico,
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilApuracaoReprocessamento,
|
||||
@ -5301,6 +5302,7 @@ def _conciliacao_analisa(
|
||||
saldo_final=conta.saldo_final,
|
||||
saldo_final_arquivo=conta.saldo_final_arquivo,
|
||||
data_base=conciliacao.data_base,
|
||||
tolerancia=conciliacao_motor.Tolerancia(conciliacao.tolerancia_valor, conciliacao.tolerancia_percentual),
|
||||
)
|
||||
|
||||
|
||||
@ -5337,6 +5339,7 @@ def _conciliacao_conta_payload(
|
||||
"valor_em_aberto": str(item.valor_em_aberto),
|
||||
"descricao": item.descricao,
|
||||
"sugestao": item.sugestao,
|
||||
"relacionados": item.relacionados,
|
||||
}
|
||||
|
||||
def situacao(lanc: ConciliacaoFornecedorLancamento) -> dict[str, Any] | None:
|
||||
@ -5348,6 +5351,9 @@ def _conciliacao_conta_payload(
|
||||
"valor_em_aberto": str(item.valor_em_aberto),
|
||||
"descricao": item.descricao,
|
||||
"sugestao": item.sugestao,
|
||||
# ids do outro lado da sugestão (id negativo = saldo de um vínculo
|
||||
# parcial, `-vinculo.id`), para a tela destacar as linhas no hover
|
||||
"relacionados": item.relacionados,
|
||||
}
|
||||
|
||||
return {
|
||||
@ -5495,7 +5501,6 @@ def _conciliacao_resposta_conta(conta: ConciliacaoFornecedorConta, sincroniza_va
|
||||
def _conciliacao_grava_contas(
|
||||
conciliacao: ConciliacaoFornecedor,
|
||||
razao: conciliacao_parser.RazaoExtraido,
|
||||
tolerancia: conciliacao_motor.Tolerancia,
|
||||
) -> None:
|
||||
"""Grava contas, vínculos sugeridos pelo motor e lançamentos. O saldo
|
||||
anterior diferente de zero vira um lançamento `eh_saldo_anterior`,
|
||||
@ -5554,8 +5559,7 @@ def _conciliacao_grava_contas(
|
||||
[
|
||||
conciliacao_motor.ItemConciliacao(indice=pos, data=linha.data, natureza=linha.natureza, valor=linha.valor)
|
||||
for pos, linha in enumerate(linhas)
|
||||
],
|
||||
tolerancia,
|
||||
]
|
||||
)
|
||||
vinculos = ConciliacaoFornecedorVinculo.objects.bulk_create(
|
||||
[ConciliacaoFornecedorVinculo(conta=conta, tipo=s.tipo, diferenca=s.diferenca) for s in sugeridos]
|
||||
@ -5705,7 +5709,7 @@ class ConciliacaoFornecedorViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
conciliacao.arquivo.save(arquivo.name, ContentFile(conteudo), save=False)
|
||||
conciliacao.save()
|
||||
_conciliacao_grava_contas(conciliacao, razao, tolerancia)
|
||||
_conciliacao_grava_contas(conciliacao, razao)
|
||||
_conciliacao_valida_conciliadas(conciliacao)
|
||||
_conciliacao_atualiza_resumo(conciliacao)
|
||||
except Exception:
|
||||
@ -5759,6 +5763,38 @@ def _conciliacao_tipo_vinculo_manual(
|
||||
return tipo, diferenca
|
||||
|
||||
|
||||
def _conciliacao_registra_historico(
|
||||
conta: ConciliacaoFornecedorConta,
|
||||
acao: str,
|
||||
vinculo: ConciliacaoFornecedorVinculo,
|
||||
lancs: list[ConciliacaoFornecedorLancamento],
|
||||
usuario: Usuario,
|
||||
acrescentados: set[int] | None = None,
|
||||
) -> None:
|
||||
"""Grava uma ação manual sobre vínculo no histórico da conta, com a cópia
|
||||
dos lançamentos envolvidos (o vínculo desfeito some do banco)."""
|
||||
ConciliacaoFornecedorVinculoHistorico.objects.create(
|
||||
conta=conta,
|
||||
acao=acao,
|
||||
vinculo_id_original=vinculo.id,
|
||||
tipo_rotulo=vinculo.get_tipo_display(),
|
||||
vinculo_automatico=vinculo.tipo not in (conciliacao_motor.TIPO_MANUAL, conciliacao_motor.TIPO_PARCIAL),
|
||||
lancamentos=[
|
||||
{
|
||||
"id": lanc.id,
|
||||
"sequencia": lanc.sequencia,
|
||||
"data": lanc.data.isoformat(),
|
||||
"natureza": lanc.natureza,
|
||||
"valor": str(lanc.valor),
|
||||
"eh_saldo_anterior": lanc.eh_saldo_anterior,
|
||||
"acrescentado": bool(acrescentados and lanc.id in acrescentados),
|
||||
}
|
||||
for lanc in sorted(lancs, key=lambda l: (l.ordem, l.id))
|
||||
],
|
||||
usuario=usuario,
|
||||
)
|
||||
|
||||
|
||||
class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
|
||||
"""Ajustes manuais numa conta: vincular lançamentos pendentes e marcar
|
||||
a conta como validada (com observação)."""
|
||||
@ -5787,8 +5823,46 @@ class ConciliacaoFornecedorContaViewSet(viewsets.GenericViewSet):
|
||||
conta=conta, tipo=tipo, diferenca=diferenca, criado_por=request.user
|
||||
)
|
||||
ConciliacaoFornecedorLancamento.objects.filter(id__in=ids).update(vinculo=vinculo)
|
||||
_conciliacao_registra_historico(
|
||||
conta, ConciliacaoFornecedorVinculoHistorico.ACAO_VINCULOU, vinculo, lancs, request.user
|
||||
)
|
||||
return _conciliacao_resposta_conta(conta)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="historico-vinculos")
|
||||
def historico_vinculos(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Histórico das ações manuais sobre vínculos da conta, mais recente
|
||||
primeiro (botão "i" do razão)."""
|
||||
conta = self.get_object()
|
||||
registros = list(conta.historico_vinculos.select_related("usuario"))
|
||||
# Histórico e contrapartida lidos na hora pelo id (o lançamento nunca
|
||||
# muda depois da importação), para valer também nos registros que já
|
||||
# existiam antes de a tela mostrá-los (pedido do usuário).
|
||||
ids = {lanc["id"] for r in registros for lanc in r.lancamentos}
|
||||
textos = {
|
||||
lanc.id: (lanc.historico, _conciliacao_rotulo_contrapartida(lanc))
|
||||
for lanc in ConciliacaoFornecedorLancamento.objects.filter(conta=conta, id__in=ids)
|
||||
}
|
||||
|
||||
def com_texto(lanc: dict[str, Any]) -> dict[str, Any]:
|
||||
historico, contrapartida = textos.get(lanc["id"], ("", ""))
|
||||
return {**lanc, "historico": historico, "contrapartida": contrapartida}
|
||||
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"id": r.id,
|
||||
"acao": r.acao,
|
||||
"acao_rotulo": r.get_acao_display(),
|
||||
"tipo_rotulo": r.tipo_rotulo,
|
||||
"vinculo_automatico": r.vinculo_automatico,
|
||||
"lancamentos": [com_texto(lanc) for lanc in r.lancamentos],
|
||||
"usuario_nome": r.usuario.nome if r.usuario else None,
|
||||
"criado_em": r.criado_em.isoformat(),
|
||||
}
|
||||
for r in registros
|
||||
]
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def validar(self, request: Request, pk: str | None = None) -> Response:
|
||||
conta = self.get_object()
|
||||
@ -5817,6 +5891,13 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
|
||||
vinculo = self.get_object()
|
||||
conta = vinculo.conta
|
||||
with transaction.atomic():
|
||||
_conciliacao_registra_historico(
|
||||
conta,
|
||||
ConciliacaoFornecedorVinculoHistorico.ACAO_DESFEZ,
|
||||
vinculo,
|
||||
list(vinculo.lancamentos.all()),
|
||||
request.user,
|
||||
)
|
||||
vinculo.delete()
|
||||
return _conciliacao_resposta_conta(conta)
|
||||
|
||||
@ -5844,4 +5925,7 @@ class ConciliacaoFornecedorVinculoViewSet(viewsets.GenericViewSet):
|
||||
vinculo.tipo, vinculo.diferenca = _conciliacao_tipo_vinculo_manual(conta.conciliacao, todos)
|
||||
vinculo.criado_por = request.user
|
||||
vinculo.save(update_fields=["tipo", "diferenca", "criado_por"])
|
||||
_conciliacao_registra_historico(
|
||||
conta, ConciliacaoFornecedorVinculoHistorico.ACAO_ACRESCENTOU, vinculo, todos, request.user, ids
|
||||
)
|
||||
return _conciliacao_resposta_conta(conta)
|
||||
|
||||
@ -662,16 +662,242 @@ button.conc-kpi.is-active {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.conc-historico {
|
||||
max-width: 380px;
|
||||
/* Histórico e sugestão em uma linha só, cortados com reticências (texto
|
||||
completo no `title`), para a altura da linha não variar com a largura da
|
||||
tela (pedido do usuário). `max-width: 0` + largura em % faz a célula ceder
|
||||
espaço em vez de alargar a tabela, igual à coluna de fornecedor. */
|
||||
.conc-subtabela td.conc-historico {
|
||||
max-width: 0;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.conc-historico__texto,
|
||||
.conc-historico__contrapartida {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conc-historico__contrapartida {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.conc-subtabela td.conc-situacao {
|
||||
max-width: 0;
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.conc-subtabela td.conc-situacao .conc-sugestao {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Conta saindo da lista por não caber mais no filtro (ex.: acabou de ser
|
||||
validada). `removerConta()` tira a linha ao fim da animação. */
|
||||
.conc-table tbody tr.is-saindo {
|
||||
animation: pidConcSaida 160ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pidConcSaida {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Botão "i" no canto da barra do razão: abre o histórico de vinculações. */
|
||||
.conc-info-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.conc-info-btn:hover,
|
||||
.conc-info-btn:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.55);
|
||||
}
|
||||
|
||||
.conc-info-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Versão menor, na linha vinculada do razão, ao lado do "Desfazer". */
|
||||
.conc-info-btn--linha {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.conc-info-btn--linha svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* Modal do detalhe de um vínculo. */
|
||||
.conc-vinculo-detalhe__criterio {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.conc-vinculo-detalhe__totais {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.conc-vinculo-detalhe__totais strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Modal "Histórico de vinculações". */
|
||||
.conc-historico-vinc {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conc-historico-vinc__lista {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__item {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-left: 3px solid #2f9e6e;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__item--acrescentou {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__item--desfez {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__cabecalho {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__tipo {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__quem {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__lancs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* seq. | data | natureza | histórico (+ contrapartida) | valor | "acrescentado" */
|
||||
.conc-historico-vinc__lanc {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 80px 64px minmax(0, 1fr) 100px auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 3px 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__lanc + .conc-historico-vinc__lanc {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* `min-width: 0` para as reticências de histórico/contrapartida valerem
|
||||
dentro da coluna flexível do grid. */
|
||||
.conc-historico-vinc__texto {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conc-historico-vinc__texto .conc-historico__texto {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__lanc.is-acrescentado {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__seq {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__novo {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conc-historico-vinc__vazio {
|
||||
margin: var(--space-3) 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Texto completo do que foi cortado acima, no visual do Portal (mesmo de
|
||||
`.dc-hover-tooltip__texto`). Criado por `pidConcMostraTooltip()`. */
|
||||
.conc-tooltip {
|
||||
position: fixed;
|
||||
/* acima de `.modal-overlay` (100) e do modal de confirmação (150): também
|
||||
é usado no modal "Histórico de vinculações" */
|
||||
z-index: 200;
|
||||
width: max-content;
|
||||
max-width: 420px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface-raised);
|
||||
color: var(--text-primary);
|
||||
/* Borda na cor do tema + sombra média: com a borda neutra e a sombra
|
||||
leve de `.dc-hover-tooltip__texto`, o balão se confundia com o fundo
|
||||
escuro da tabela (pedido do usuário). */
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.55);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.75rem;
|
||||
font-style: normal;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: pidFadeIn 120ms ease-out;
|
||||
}
|
||||
|
||||
.conc-natureza {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
@ -1015,6 +1241,15 @@ button.conc-kpi.is-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Hover na sugestão: a linha e as do outro lado da sugestão. Dourado (a
|
||||
cor das situações "em aberto há mais de 3 meses"/"pagamento não
|
||||
vinculado"): não o verde de `.is-grupo`, porque a sugestão ainda não é
|
||||
vínculo, nem o roxo, que já é o de linha selecionada (`.is-selecionado`). */
|
||||
.pa-table .conc-subtabela--pendentes tbody tr.is-sugestao-grupo td {
|
||||
background: rgba(var(--gold-rgb), 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conc-valor--saldo {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@ -122,7 +122,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<li class="ag-row${canManage ? " ag-row--draggable" : ""}" data-acesso-id="${acesso.id}" ${canManage ? 'draggable="true"' : ""}>
|
||||
<button type="button" class="ag-row__open" data-acesso-open="${acesso.id}">
|
||||
${PID_AG_ROW_ICON}
|
||||
<span class="ag-row__label">${acesso.nome}</span>
|
||||
<span class="ag-row__label">${pidEscapeHtml(acesso.nome)}</span>
|
||||
</button>
|
||||
<div class="ag-row__admin">${adminHtml}</div>
|
||||
</li>`;
|
||||
@ -156,7 +156,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
return `
|
||||
<section class="ag-section" data-secao-id="${secao.id}">
|
||||
<div class="ag-section__header">
|
||||
<h3 class="ag-section__title">${secao.nome}</h3>
|
||||
<h3 class="ag-section__title">${pidEscapeHtml(secao.nome)}</h3>
|
||||
${actionsHtml}
|
||||
</div>
|
||||
<ul class="ag-row-list" data-secao-list="${secao.id}">${rowsHtml}</ul>
|
||||
@ -200,7 +200,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
(p) => `
|
||||
<label class="checklist-item">
|
||||
<input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} />
|
||||
<span>${p.nome}</span>
|
||||
<span>${pidEscapeHtml(p.nome)}</span>
|
||||
</label>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
@ -10,6 +10,22 @@ async function pidEnsureCsrfCookie() {
|
||||
await fetch(`${PID_API_BASE}/auth/csrf/`, { credentials: "include" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape de texto para montar HTML por template string (`innerHTML`). Vale
|
||||
* para conteúdo **e** para valor de atributo entre aspas duplas ou simples:
|
||||
* trata `& < > " '`. Todo dado vindo do servidor, do usuário ou de arquivo
|
||||
* anexado passa por aqui antes de entrar num `innerHTML` (senão um texto com
|
||||
* HTML vira código executado no navegador de quem abre a tela: XSS). Nunca
|
||||
* usar em HTML que já é intencional (texto rico sanitizado no servidor com
|
||||
* nh3, como a Ajuda e as observações de Acessos Gerais).
|
||||
*/
|
||||
const PID_ESCAPE_HTML_MAPA = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
|
||||
function pidEscapeHtml(texto) {
|
||||
if (texto === null || texto === undefined) return "";
|
||||
return String(texto).replace(/[&<>"']/g, (ch) => PID_ESCAPE_HTML_MAPA[ch]);
|
||||
}
|
||||
|
||||
function pidErrorMessageFrom(data) {
|
||||
if (!data) return "Erro ao processar a solicitação.";
|
||||
if (typeof data === "string") return data;
|
||||
|
||||
@ -115,7 +115,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
function popularSelectCategorias(selecionarId) {
|
||||
modalCategoriaSelect.innerHTML =
|
||||
'<option value="">Nenhuma</option>' +
|
||||
categorias.map((c) => `<option value="${c.id}">${c.nome}</option>`).join("");
|
||||
categorias.map((c) => `<option value="${pidEscapeHtml(c.id)}">${pidEscapeHtml(c.nome)}</option>`).join("");
|
||||
if (selecionarId) modalCategoriaSelect.value = String(selecionarId);
|
||||
}
|
||||
|
||||
@ -136,7 +136,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
const meusDepartamentos = me.departamentos || [];
|
||||
modalDepartmentSelect.innerHTML = meusDepartamentos
|
||||
.map((d) => `<option value="${d.id}">${d.nome}</option>`)
|
||||
.map((d) => `<option value="${pidEscapeHtml(d.id)}">${pidEscapeHtml(d.nome)}</option>`)
|
||||
.join("");
|
||||
|
||||
function updateDepartmentFieldVisibility() {
|
||||
|
||||
@ -32,17 +32,199 @@ const PID_CONC_ROTULO_ALERTA = {
|
||||
|
||||
const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" };
|
||||
|
||||
// Delegado ao `pidEscapeHtml` (api.js), que já escapa aspas: serve para
|
||||
// conteúdo e para atributo. `pidConcEscapeAttr` fica como sinônimo.
|
||||
function pidConcEscape(texto) {
|
||||
if (texto == null) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(texto);
|
||||
return div.innerHTML;
|
||||
return pidEscapeHtml(texto);
|
||||
}
|
||||
|
||||
function pidConcEscapeAttr(texto) {
|
||||
return pidEscapeHtml(texto);
|
||||
}
|
||||
|
||||
/** Célula de histórico em uma linha só (reticências + texto completo no
|
||||
* hover); a contrapartida segue na segunda linha, também cortada. */
|
||||
function pidConcHistoricoHtml(lanc) {
|
||||
const texto = lanc.eh_saldo_anterior ? "Saldo anterior" : lanc.historico || "";
|
||||
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(texto);
|
||||
const contrapartida = lanc.contrapartida
|
||||
? `<span class="conc-historico__contrapartida" data-conc-tooltip="${pidConcEscapeAttr(lanc.contrapartida)}">${pidConcEscape(lanc.contrapartida)}</span>`
|
||||
: "";
|
||||
return `<td class="conc-historico"><span class="conc-historico__texto" data-conc-tooltip="${pidConcEscapeAttr(texto)}">${historico}</span>${contrapartida}</td>`;
|
||||
}
|
||||
|
||||
// Tooltip do texto cortado com reticências (`[data-conc-tooltip]`), no
|
||||
// visual do Portal em vez do `title` nativo (pedido do usuário; mesmo
|
||||
// visual de `.dc-hover-tooltip__texto` do Relatório Contábil). Um único
|
||||
// elemento `position: fixed` no body (as tabelas cortam `absolute`),
|
||||
// delegado no document para valer nas linhas recriadas a cada re-render.
|
||||
// Só aparece quando o texto está de fato cortado, salvo com
|
||||
// `data-conc-tooltip-sempre` (rótulo de botão só com ícone).
|
||||
let pidConcTooltipEl = null;
|
||||
|
||||
function pidConcMostraTooltip(evento) {
|
||||
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
|
||||
if (!alvo) return;
|
||||
if (!("concTooltipSempre" in alvo.dataset) && alvo.scrollWidth <= alvo.clientWidth) return;
|
||||
if (!pidConcTooltipEl) {
|
||||
pidConcTooltipEl = document.createElement("div");
|
||||
pidConcTooltipEl.className = "conc-tooltip";
|
||||
pidConcTooltipEl.setAttribute("role", "tooltip");
|
||||
document.body.appendChild(pidConcTooltipEl);
|
||||
}
|
||||
pidConcTooltipEl.textContent = alvo.dataset.concTooltip;
|
||||
pidConcTooltipEl.hidden = false;
|
||||
|
||||
const margem = 8;
|
||||
const rect = alvo.getBoundingClientRect();
|
||||
const largura = pidConcTooltipEl.offsetWidth;
|
||||
const altura = pidConcTooltipEl.offsetHeight;
|
||||
const esquerda = Math.max(margem, Math.min(rect.left, window.innerWidth - largura - margem));
|
||||
let topo = rect.top - altura - margem;
|
||||
if (topo < margem) topo = rect.bottom + margem;
|
||||
pidConcTooltipEl.style.left = `${esquerda}px`;
|
||||
pidConcTooltipEl.style.top = `${topo}px`;
|
||||
}
|
||||
|
||||
function pidConcEscondeTooltip(evento) {
|
||||
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
|
||||
if (!alvo || !pidConcTooltipEl || alvo.contains(evento.relatedTarget)) return;
|
||||
pidConcTooltipEl.hidden = true;
|
||||
}
|
||||
|
||||
document.addEventListener("mouseover", pidConcMostraTooltip);
|
||||
document.addEventListener("mouseout", pidConcEscondeTooltip);
|
||||
window.addEventListener("scroll", () => { if (pidConcTooltipEl) pidConcTooltipEl.hidden = true; }, true);
|
||||
|
||||
function pidConcMoeda(valor) {
|
||||
return PID_CONC_MOEDA.format(Number(valor) || 0);
|
||||
}
|
||||
|
||||
const PID_CONC_ICONE_INFO =
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-5M12 8h.01" stroke-linecap="round"/></svg>';
|
||||
|
||||
// ---- Histórico de vinculações da conta (botão "i" do razão) ----
|
||||
// Pedido do usuário: saber quem vinculou/desvinculou o quê e com qual seq.
|
||||
// Modal criado sob demanda e injetado no body (padrão de ajuda-aplicacao.js).
|
||||
let pidConcHistoricoModal = null;
|
||||
|
||||
function pidConcHistoricoLancamentoHtml(lanc) {
|
||||
const seq = lanc.eh_saldo_anterior ? "Saldo anterior" : `Seq. ${pidConcEscape(lanc.sequencia || "—")}`;
|
||||
return `
|
||||
<li class="conc-historico-vinc__lanc${lanc.acrescentado ? " is-acrescentado" : ""}">
|
||||
<span class="conc-historico-vinc__seq">${seq}</span>
|
||||
<span>${pidConcData(lanc.data)}</span>
|
||||
<span><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></span>
|
||||
<span class="conc-historico-vinc__texto">
|
||||
<span class="conc-historico__texto" data-conc-tooltip="${pidConcEscapeAttr(lanc.historico || "")}">${lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico || "")}</span>
|
||||
${lanc.contrapartida ? `<span class="conc-historico__contrapartida" data-conc-tooltip="${pidConcEscapeAttr(lanc.contrapartida)}">${pidConcEscape(lanc.contrapartida)}</span>` : ""}
|
||||
</span>
|
||||
<span class="conc-num">${pidConcMoeda(lanc.valor)}</span>
|
||||
${lanc.acrescentado ? '<span class="conc-historico-vinc__novo">acrescentado</span>' : ""}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function pidConcHistoricoRegistroHtml(registro) {
|
||||
const origem = registro.vinculo_automatico ? "automático" : "manual";
|
||||
return `
|
||||
<li class="conc-historico-vinc__item conc-historico-vinc__item--${registro.acao}">
|
||||
<div class="conc-historico-vinc__cabecalho">
|
||||
<strong>${pidConcEscape(registro.acao_rotulo)}</strong>
|
||||
<span class="conc-historico-vinc__tipo">${pidConcEscape(registro.tipo_rotulo)} · ${origem}</span>
|
||||
<span class="conc-historico-vinc__quem">${pidConcEscape(registro.usuario_nome || "—")} · ${pidConcDataHora(registro.criado_em)}</span>
|
||||
</div>
|
||||
<ul class="conc-historico-vinc__lancs">${registro.lancamentos.map(pidConcHistoricoLancamentoHtml).join("")}</ul>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
/** Abre o modal de informação (um só, reaproveitado pelo histórico de
|
||||
* vinculações e pelo detalhe de um vínculo) e devolve o corpo a preencher. */
|
||||
function pidConcAbreModalInfo(titulo, subtitulo) {
|
||||
if (!pidConcHistoricoModal) {
|
||||
pidConcHistoricoModal = document.createElement("div");
|
||||
pidConcHistoricoModal.className = "modal-overlay";
|
||||
pidConcHistoricoModal.hidden = true;
|
||||
pidConcHistoricoModal.innerHTML = `
|
||||
<div class="modal-card modal-card--wide">
|
||||
<h2 class="modal-card__title" data-historico-titulo></h2>
|
||||
<p class="modal-card__subtitle" data-historico-subtitulo></p>
|
||||
<div class="conc-historico-vinc" data-historico-corpo></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" data-historico-fechar>Fechar</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(pidConcHistoricoModal);
|
||||
const fechar = () => { pidConcHistoricoModal.hidden = true; };
|
||||
pidConcHistoricoModal.querySelector("[data-historico-fechar]").addEventListener("click", fechar);
|
||||
pidConcHistoricoModal.addEventListener("click", (evento) => {
|
||||
if (evento.target === pidConcHistoricoModal) fechar();
|
||||
});
|
||||
}
|
||||
pidConcHistoricoModal.querySelector("[data-historico-titulo]").textContent = titulo;
|
||||
pidConcHistoricoModal.querySelector("[data-historico-subtitulo]").textContent = subtitulo;
|
||||
pidConcHistoricoModal.hidden = false;
|
||||
return pidConcHistoricoModal.querySelector("[data-historico-corpo]");
|
||||
}
|
||||
|
||||
// ---- Detalhe de um vínculo (botão "i" na linha vinculada) ----
|
||||
// Pedido do usuário: ver o que o vínculo automático considerou sem caçar as
|
||||
// outras linhas no razão (podem estar longe). Tudo já vem no payload da conta.
|
||||
const PID_CONC_CRITERIO_VINCULO = {
|
||||
exato: "Um crédito e um débito de mesmo valor. Havendo mais de um candidato, fica o de data mais próxima (e, no empate, o pagamento feito depois do título).",
|
||||
combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima).",
|
||||
sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível.",
|
||||
saldo_zerado: "Trecho do razão em que o saldo da conta voltou a zero: o que sobrou nele se compensa por definição, então vira um único vínculo.",
|
||||
tolerancia: "Débito e crédito (ou dois itens) com uma diferença pequena, dentro da tolerância da conciliação (valor fixo ou percentual do título, o maior). Possível juros/desconto. Critério usado só em importações até 25/09/2026: hoje esse caso fica pendente, como sugestão.",
|
||||
manual: "Vinculado manualmente.",
|
||||
parcial: "Pagamento parcial vinculado manualmente: a diferença continua em aberto.",
|
||||
};
|
||||
|
||||
function pidConcAbreDetalheVinculo(conta, vinculoId, numero) {
|
||||
const vinculo = conta.vinculos.find((v) => v.id === vinculoId);
|
||||
if (!vinculo) return;
|
||||
const lancs = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId);
|
||||
const creditos = lancs.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
|
||||
const debitos = lancs.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
|
||||
const diferenca = Math.round((creditos - debitos) * 100) / 100;
|
||||
const automatico = vinculo.tipo !== "manual" && vinculo.tipo !== "parcial";
|
||||
let criterio = PID_CONC_CRITERIO_VINCULO[vinculo.tipo] || "";
|
||||
if (!automatico && vinculo.criado_por_nome) criterio = `${criterio} Por ${vinculo.criado_por_nome}.`;
|
||||
const resultado = diferenca === 0
|
||||
? "Fecha sem diferença."
|
||||
: diferenca > 0
|
||||
? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos${vinculo.tipo === "parcial" ? " (em aberto)" : ""}.`
|
||||
: `Débitos ${pidConcMoeda(-diferenca)} maiores que os créditos.`;
|
||||
|
||||
const corpo = pidConcAbreModalInfo(`Vínculo #${numero || "?"}`, `${conta.conta_numero} - ${conta.fornecedor}`);
|
||||
corpo.innerHTML = `
|
||||
<div class="conc-historico-vinc__item conc-historico-vinc__item--vinculou">
|
||||
<div class="conc-historico-vinc__cabecalho">
|
||||
<strong>${pidConcEscape(vinculo.tipo_rotulo)}</strong>
|
||||
<span class="conc-historico-vinc__tipo">${automatico ? "automático" : "manual"}</span>
|
||||
</div>
|
||||
<p class="conc-vinculo-detalhe__criterio">${pidConcEscape(criterio)}</p>
|
||||
<ul class="conc-historico-vinc__lancs">${lancs.map(pidConcHistoricoLancamentoHtml).join("")}</ul>
|
||||
<div class="conc-vinculo-detalhe__totais">
|
||||
<span>Créditos <strong>${pidConcMoeda(creditos)}</strong></span>
|
||||
<span>Débitos <strong>${pidConcMoeda(debitos)}</strong></span>
|
||||
<span>${resultado}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function pidConcAbreHistoricoVinculos(conta) {
|
||||
const corpo = pidConcAbreModalInfo("Histórico de vinculações", `${conta.conta_numero} - ${conta.fornecedor}`);
|
||||
corpo.innerHTML = '<p class="conc-historico-vinc__vazio">Carregando…</p>';
|
||||
try {
|
||||
const registros = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/historico-vinculos/`);
|
||||
corpo.innerHTML = registros.length
|
||||
? `<ul class="conc-historico-vinc__lista">${registros.map(pidConcHistoricoRegistroHtml).join("")}</ul>`
|
||||
: '<p class="conc-historico-vinc__vazio">Nenhuma vinculação ou desvinculação manual registrada nesta conta.</p>';
|
||||
} catch (erro) {
|
||||
corpo.innerHTML = `<p class="modal-error">${pidConcEscape(erro.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */
|
||||
function pidConcSaldo(valor) {
|
||||
const numero = Number(valor) || 0;
|
||||
@ -79,7 +261,7 @@ function pidConcValorAbertoHtml(situacao) {
|
||||
function pidConcSituacaoHtml(situacao) {
|
||||
if (!situacao) return '<td class="conc-situacao"></td>';
|
||||
const sugestao = situacao.sugestao
|
||||
? `<span class="conc-sugestao"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(situacao.sugestao)}</span>`
|
||||
? `<span class="conc-sugestao" data-conc-tooltip="${pidConcEscapeAttr(situacao.sugestao)}" data-conc-relacionados="${(situacao.relacionados || []).join(",")}"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(situacao.sugestao)}</span>`
|
||||
: "";
|
||||
return `<td class="conc-situacao conc-situacao--${situacao.categoria}">${pidConcEscape(situacao.descricao)}${sugestao}</td>`;
|
||||
}
|
||||
@ -499,24 +681,28 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
}
|
||||
|
||||
function contasFiltradas() {
|
||||
/** A conta aparece com o filtro atual? Usado pela lista inteira e depois
|
||||
* de cada ajuste numa conta (`aplicarRespostaConta`). */
|
||||
function contaPassaFiltro(c) {
|
||||
const termo = contasBusca.value.trim().toLowerCase();
|
||||
let lista = detalhe.contas.filter((c) => {
|
||||
// Validadas ficam fora por padrão (pedido do usuário): a lista mostra
|
||||
// o que ainda falta revisar. O chip "Validados" as mostra sempre.
|
||||
if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false;
|
||||
if (filtroAlerta) {
|
||||
const temAlerta =
|
||||
filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta);
|
||||
if (!temAlerta) return false;
|
||||
} else if (filtroStatus === "validada") {
|
||||
// validação é uma marcação à parte da situação calculada
|
||||
if (!c.validada) return false;
|
||||
} else if (filtroStatus && c.status !== filtroStatus) {
|
||||
return false;
|
||||
}
|
||||
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
|
||||
});
|
||||
// Validadas ficam fora por padrão (pedido do usuário): a lista mostra
|
||||
// o que ainda falta revisar. O chip "Validados" as mostra sempre.
|
||||
if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false;
|
||||
if (filtroAlerta) {
|
||||
const temAlerta =
|
||||
filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta);
|
||||
if (!temAlerta) return false;
|
||||
} else if (filtroStatus === "validada") {
|
||||
// validação é uma marcação à parte da situação calculada
|
||||
if (!c.validada) return false;
|
||||
} else if (filtroStatus && c.status !== filtroStatus) {
|
||||
return false;
|
||||
}
|
||||
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
|
||||
}
|
||||
|
||||
function contasFiltradas() {
|
||||
let lista = detalhe.contas.filter(contaPassaFiltro);
|
||||
if (sortKey) {
|
||||
const extrator = sortValor[sortKey];
|
||||
lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir);
|
||||
@ -626,19 +812,40 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const estado = estadoDe(resposta.conta.id);
|
||||
estado.selecionados.clear();
|
||||
renderCards();
|
||||
substituirConta(resposta.conta);
|
||||
// Conta que deixou de caber no filtro (validada com "Mostrar validadas"
|
||||
// desmarcado, desmarcada no chip "Validados", mudou de situação com um
|
||||
// chip de situação ativo) sai da lista na hora. Antes ela só era
|
||||
// redesenhada no lugar e saía no próximo render da lista inteira
|
||||
// (pedido do usuário: "tem um atraso, às vezes preciso atualizar").
|
||||
if (contaPassaFiltro(resposta.conta)) substituirConta(resposta.conta);
|
||||
else removerConta(resposta.conta.id);
|
||||
}
|
||||
|
||||
/** Tira as linhas da conta da lista com um fade curto. */
|
||||
function removerConta(contaId) {
|
||||
const linhas = contasBody.querySelectorAll(`tr[data-conta-id="${contaId}"], tr[data-conta-detalhe-id="${contaId}"]`);
|
||||
linhas.forEach((tr) => tr.classList.add("is-saindo"));
|
||||
window.setTimeout(() => {
|
||||
linhas.forEach((tr) => tr.remove());
|
||||
if (!contasBody.querySelector("tr[data-conta-id]")) {
|
||||
contasVazia.hidden = false;
|
||||
contasVazia.textContent =
|
||||
!mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim()
|
||||
? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los."
|
||||
: "Nenhum fornecedor para este filtro.";
|
||||
}
|
||||
}, 160);
|
||||
}
|
||||
|
||||
function linhaLancamentoHtml(lanc, comCheckbox, selecionado) {
|
||||
const situacao = lanc.situacao;
|
||||
const valorAberto = pidConcValorAbertoHtml(situacao);
|
||||
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
|
||||
return `
|
||||
<tr class="${selecionado ? "is-selecionado" : ""}" data-lanc-id="${lanc.id}">
|
||||
${comCheckbox ? `<td class="conc-subtabela__check"><input type="checkbox" data-lanc-check="${lanc.id}" ${selecionado ? "checked" : ""} aria-label="Selecionar lançamento" /></td>` : ""}
|
||||
<td>${pidConcData(lanc.data)}</td>
|
||||
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
|
||||
<td class="conc-historico">${historico}${lanc.contrapartida ? `<span class="conc-historico__contrapartida">${pidConcEscape(lanc.contrapartida)}</span>` : ""}</td>
|
||||
${pidConcHistoricoHtml(lanc)}
|
||||
<td><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></td>
|
||||
<td class="conc-num conc-valor">${pidConcMoeda(lanc.valor)}</td>
|
||||
${comCheckbox ? `<td class="conc-num conc-valor">${valorAberto}</td>${pidConcSituacaoHtml(situacao)}` : ""}
|
||||
@ -688,6 +895,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<div class="conc-detail__acoes">
|
||||
<span class="conc-selecao-resumo" data-selecao-resumo></span>
|
||||
<button type="button" class="btn-solid conc-btn-sm" data-vincular disabled>Vincular selecionados</button>
|
||||
<button type="button" class="conc-info-btn" data-historico-vinculos aria-label="Histórico de vinculações" data-conc-tooltip="Histórico de vinculações" data-conc-tooltip-sempre>${PID_CONC_ICONE_INFO}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
if (razao.length) {
|
||||
@ -704,12 +912,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
painel.appendChild(secaoPend);
|
||||
|
||||
function linhaRazaoHtml(lanc) {
|
||||
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
|
||||
const contrapartida = lanc.contrapartida ? `<span class="conc-historico__contrapartida">${pidConcEscape(lanc.contrapartida)}</span>` : "";
|
||||
const comum = `
|
||||
<td>${pidConcData(lanc.data)}</td>
|
||||
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
|
||||
<td class="conc-historico">${historico}${contrapartida}</td>
|
||||
${pidConcHistoricoHtml(lanc)}
|
||||
<td><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></td>
|
||||
<td class="conc-num conc-valor">${pidConcMoeda(lanc.valor)}</td>
|
||||
<td class="conc-num conc-valor conc-valor--saldo">${lanc.saldo_apos === null ? "—" : pidConcSaldo(lanc.saldo_apos)}</td>`;
|
||||
@ -735,6 +941,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<span class="conc-vinculado-tag">Vinculado #${numeroVinculo.get(lanc.vinculo_id) || "?"}</span>
|
||||
<span class="conc-vinculado__tipo">${pidConcEscape(vinculo ? vinculo.tipo_rotulo : "")} · ${origem}</span>
|
||||
<button type="button" class="conc-link-btn" data-desfazer-vinculo="${lanc.vinculo_id}">Desfazer</button>
|
||||
<button type="button" class="conc-info-btn conc-info-btn--linha" data-ver-vinculo="${lanc.vinculo_id}" aria-label="Ver o vínculo" data-conc-tooltip="Ver o que este vínculo considerou" data-conc-tooltip-sempre>${PID_CONC_ICONE_INFO}</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
@ -765,6 +972,43 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo")));
|
||||
tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-grupo")));
|
||||
});
|
||||
// Sugestão: ao passar o mouse, destaca a própria linha e as do outro
|
||||
// lado da sugestão (pedido do usuário, como o destaque do vínculo). Id
|
||||
// negativo é o saldo de um vínculo parcial: destaca as linhas dele.
|
||||
secaoPend.querySelectorAll("[data-conc-relacionados]").forEach((sugestaoEl) => {
|
||||
const linha = sugestaoEl.closest("tr");
|
||||
const ids = sugestaoEl.dataset.concRelacionados.split(",").filter(Boolean).map(Number);
|
||||
const grupo = () => [
|
||||
linha,
|
||||
...ids.flatMap((id) =>
|
||||
Array.from(secaoPend.querySelectorAll(id < 0 ? `tr[data-vinculo-id="${-id}"]` : `tr[data-lanc-id="${id}"]`))
|
||||
),
|
||||
];
|
||||
sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo")));
|
||||
sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo")));
|
||||
// Duplo clique seleciona a linha e as relacionadas; se já estavam todas
|
||||
// selecionadas, desmarca (pedido do usuário). Só as não vinculadas
|
||||
// (com checkbox): o saldo de vínculo parcial fica só no destaque.
|
||||
sugestaoEl.addEventListener("dblclick", (evento) => {
|
||||
evento.stopPropagation();
|
||||
if (window.getSelection) window.getSelection().removeAllRanges();
|
||||
const checks = grupo()
|
||||
.map((tr) => tr.querySelector("[data-lanc-check]"))
|
||||
.filter(Boolean);
|
||||
const marcar = !checks.every((check) => check.checked);
|
||||
checks.forEach((check) => {
|
||||
if (check.checked === marcar) return;
|
||||
check.checked = marcar;
|
||||
check.dispatchEvent(new Event("change"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
secaoPend.querySelector("[data-historico-vinculos]").addEventListener("click", (evento) => {
|
||||
evento.stopPropagation();
|
||||
pidConcAbreHistoricoVinculos(conta);
|
||||
});
|
||||
|
||||
// "Ocultar conciliados": desmarcado por padrão (pedido do usuário); a
|
||||
// escolha fica no estado da conta e sobrevive ao re-render após um vínculo.
|
||||
const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]");
|
||||
@ -777,6 +1021,14 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
}
|
||||
|
||||
secaoPend.querySelectorAll("[data-ver-vinculo]").forEach((btn) => {
|
||||
btn.addEventListener("click", (evento) => {
|
||||
evento.stopPropagation();
|
||||
const vinculoId = Number(btn.dataset.verVinculo);
|
||||
pidConcAbreDetalheVinculo(conta, vinculoId, numeroVinculo.get(vinculoId));
|
||||
});
|
||||
});
|
||||
|
||||
secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => {
|
||||
btn.addEventListener("click", (evento) => {
|
||||
evento.stopPropagation();
|
||||
@ -832,6 +1084,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id])").forEach((tr) => {
|
||||
tr.addEventListener("click", (evento) => {
|
||||
if (evento.target.closest("[data-lanc-check]")) return;
|
||||
// A sugestão é reservada ao duplo clique (seleciona o grupo); o
|
||||
// clique simples nela marcaria/desmarcaria a linha duas vezes antes.
|
||||
if (evento.target.closest("[data-conc-relacionados]")) return;
|
||||
if (String(window.getSelection ? window.getSelection() : "").trim()) return;
|
||||
const check = tr.querySelector("[data-lanc-check]");
|
||||
check.checked = !check.checked;
|
||||
|
||||
@ -178,10 +178,11 @@ function pidDcFormatDataHora(iso) {
|
||||
});
|
||||
}
|
||||
|
||||
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas: esta função
|
||||
// é usada dentro de atributos (`aria-label`, `value`), e a versão antiga por
|
||||
// `textContent` deixava `"` passar (XSS por texto de observação).
|
||||
function pidDcEscapeHtml(texto) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = texto == null ? "" : String(texto);
|
||||
return div.innerHTML;
|
||||
return pidEscapeHtml(texto);
|
||||
}
|
||||
|
||||
// Barra de progresso de "Nova Análise"/"Reprocessar" — o processamento roda
|
||||
@ -616,7 +617,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<td>${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}</td>
|
||||
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
|
||||
<td>${apuracao.total_achados_pendentes}</td>
|
||||
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
|
||||
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${pidDcEscapeHtml(PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status)}</span></td>
|
||||
<td>${pidDcEscapeHtml(apuracao.criado_por_nome || "-")}</td>
|
||||
<td>${pidDcFormatData(apuracao.criado_em)}</td>
|
||||
<td class="pa-row-actions">
|
||||
@ -685,7 +686,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
}
|
||||
|
||||
function dcEscapeAttr(value) {
|
||||
return (value === null || value === undefined ? "" : String(value)).replace(/"/g, """);
|
||||
return pidEscapeHtml(value);
|
||||
}
|
||||
|
||||
function dcCriarFiltroColuna(campo) {
|
||||
@ -1310,8 +1311,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
card.innerHTML = `
|
||||
<div class="dc-achado-card__main">
|
||||
<div class="dc-achado-card__titulo">
|
||||
<span class="dc-badge dc-badge--${achado.severidade}">${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade}</span>
|
||||
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
|
||||
<span class="dc-badge dc-badge--${achado.severidade}">${pidDcEscapeHtml(PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade)}</span>
|
||||
<span class="dc-badge dc-badge--${achado.status}">${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)}</span>
|
||||
${pidDcEscapeHtml(achado.titulo)}
|
||||
</div>
|
||||
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
|
||||
@ -2813,7 +2814,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<div class="dc-achado-card dc-achado-card--${achado.severidade} dc-variacao${achado.status === "tratado" ? " dc-variacao--validada" : ""}${linhaId != null && dcVariacaoFoco === linhaId ? " dc-variacao--foco" : ""}" data-dc-variacao-linha="${linhaId ?? ""}">
|
||||
<div class="dc-achado-card__main">
|
||||
<div class="dc-achado-card__titulo">
|
||||
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
|
||||
<span class="dc-badge dc-badge--${achado.status}">${pidDcEscapeHtml(PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status)}</span>
|
||||
${pidDcEscapeHtml(achado.titulo)}
|
||||
</div>
|
||||
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
|
||||
@ -3251,7 +3252,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn dc-dash-toggle-btn"
|
||||
data-dc-dash-indicador-toggle="${chave}"
|
||||
data-dc-dash-indicador-toggle="${pidDcEscapeHtml(chave)}"
|
||||
aria-label="${oculto ? "Mostrar" : "Ocultar"} no relatório"
|
||||
title="${oculto ? "Mostrar no relatório" : "Ocultar do relatório"}"
|
||||
${concluida ? "disabled" : ""}
|
||||
@ -3775,7 +3776,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
: `<input
|
||||
type="checkbox"
|
||||
class="dc-ind-hub-item__ativo"
|
||||
data-dc-hub-toggle="${definicao.chave}"
|
||||
data-dc-hub-toggle="${pidDcEscapeHtml(definicao.chave)}"
|
||||
${ativo ? "checked" : ""}
|
||||
${concluida ? "disabled" : ""}
|
||||
aria-label="Ativo nesta apuração"
|
||||
|
||||
@ -69,8 +69,8 @@ function pidCriarSeletorDuplo(config) {
|
||||
(c) => `
|
||||
<tr>
|
||||
<td><input type="checkbox" data-dual-select="${c.id}" ${state.selected.has(c.id) ? "checked" : ""} /></td>
|
||||
<td>${c.nome}</td>
|
||||
<td>${secundariaTexto(c)}</td>
|
||||
<td>${pidEscapeHtml(c.nome)}</td>
|
||||
<td>${pidEscapeHtml(secundariaTexto(c))}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
|
||||
@ -120,10 +120,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const cardsHtml = favApps
|
||||
.map(
|
||||
(app) => `
|
||||
<article class="app-card" data-fav-id="${app.id}" role="button" tabindex="0" draggable="true">
|
||||
<article class="app-card" data-fav-id="${pidEscapeHtml(app.id)}" role="button" tabindex="0" draggable="true">
|
||||
${pidIconHtmlFor(app)}
|
||||
<span class="app-card__label">${app.label}</span>
|
||||
<button type="button" class="app-card__remove" data-fav-remove="${app.id}" aria-label="Remover dos favoritos" draggable="false">
|
||||
<span class="app-card__label">${pidEscapeHtml(app.label)}</span>
|
||||
<button type="button" class="app-card__remove" data-fav-remove="${pidEscapeHtml(app.id)}" aria-label="Remover dos favoritos" draggable="false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8 0 1 12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1l1-12" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</article>`
|
||||
|
||||
@ -784,11 +784,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const statusTexto = imp.status === "concluida" ? "Concluída" : "Em revisão";
|
||||
tr.innerHTML = `
|
||||
<td class="ips-select-col"><input type="checkbox" class="ips-select-row" data-select-id="${imp.id}" aria-label="Selecionar esta importação" ${selectedIds.has(imp.id) ? "checked" : ""} /></td>
|
||||
<td>${imp.codigo_empresa || "—"}</td>
|
||||
<td>${imp.nome_operadora}</td>
|
||||
<td>${tiposLabel(imp.tipos_lancamento)}</td>
|
||||
<td>${escapeHtml(imp.codigo_empresa || "—")}</td>
|
||||
<td>${escapeHtml(imp.nome_operadora)}</td>
|
||||
<td>${escapeHtml(tiposLabel(imp.tipos_lancamento))}</td>
|
||||
<td><span class="status-pill ${statusClasse}">${statusTexto}</span></td>
|
||||
<td>${imp.criado_por_nome || "—"}</td>
|
||||
<td>${escapeHtml(imp.criado_por_nome || "—")}</td>
|
||||
<td>${criadoEm}</td>
|
||||
<td class="ips-table-actions">
|
||||
<button type="button" class="icon-btn ips-remove-linha" data-remover-importacao="${imp.id}" aria-label="Excluir importação" title="Excluir importação">
|
||||
@ -1457,7 +1457,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<p class="ips-regra-row__nome">${escapeHtml(r.label)}</p>
|
||||
</div>
|
||||
<div class="ips-regra-row__actions">
|
||||
<button type="button" class="btn-outline" data-regra-empresa-selecionar="${r.key}">Selecionar</button>
|
||||
<button type="button" class="btn-outline" data-regra-empresa-selecionar="${escapeAttr(r.key)}">Selecionar</button>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
@ -1829,27 +1829,24 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
tabsEl.innerHTML = tabKeys
|
||||
.map((key) => {
|
||||
const label = key === "auditoria" ? "Auditoria" : key === "alteracoes" ? "Alterações" : PID_IPS_TIPO_LABELS[key] || key;
|
||||
return `<button type="button" class="pa-tab${key === activeTab ? " is-active" : ""}" data-ips-tab="${key}">${label}</button>`;
|
||||
return `<button type="button" class="pa-tab${key === activeTab ? " is-active" : ""}" data-ips-tab="${escapeAttr(key)}">${escapeHtml(label)}</button>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
renderPanels();
|
||||
}
|
||||
|
||||
// As duas delegam ao `pidEscapeHtml` (api.js), que trata `& < > " '`. O
|
||||
// comentário antigo dizia que só o texto livre das regras de custeio
|
||||
// precisava de escape completo e que o resto vinha de "fontes fixas", mas
|
||||
// nome, CPF e detalhe da auditoria vêm do arquivo anexado da operadora:
|
||||
// todo dado entra escapado (revisão de interface de 2026-09-25, XSS).
|
||||
function escapeAttr(value) {
|
||||
return (value === null || value === undefined ? "" : String(value)).replace(/"/g, """);
|
||||
return pidEscapeHtml(value);
|
||||
}
|
||||
|
||||
// Igual a escapeAttr, mas também escapa "<"/">"/"&" — usado pra inserir via
|
||||
// innerHTML texto livre digitado pelo colaborador (nome/observações de uma
|
||||
// regra de custeio salva), que ao contrário do resto dos campos desta tela
|
||||
// (nomes de operadora/arquivo, vindos de fontes fixas) pode conter
|
||||
// qualquer caractere.
|
||||
function escapeHtml(value) {
|
||||
return (value === null || value === undefined ? "" : String(value)).replace(
|
||||
/[&<>"]/g,
|
||||
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]
|
||||
);
|
||||
return pidEscapeHtml(value);
|
||||
}
|
||||
|
||||
function resizeHandleHtml(campo, target) {
|
||||
@ -2107,7 +2104,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
</div>
|
||||
${concluida ? "" : `
|
||||
<div class="ips-add-row">
|
||||
<button type="button" class="btn-outline" data-ips-add-linha="${tipo}">
|
||||
<button type="button" class="btn-outline" data-ips-add-linha="${escapeAttr(tipo)}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>
|
||||
Adicionar linha
|
||||
</button>
|
||||
@ -2141,7 +2138,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
let acaoHtml = `<span class="ips-empty-cell">—</span>`;
|
||||
if (item.resolvida) {
|
||||
acaoHtml = `<span class="ips-auditoria-resolvido" title="Vinculado a: ${escapeAttr(item.linha_vinculada_nome || "")}">Resolvido${
|
||||
item.linha_vinculada_nome ? ` — ${item.linha_vinculada_nome}` : ""
|
||||
item.linha_vinculada_nome ? ` — ${escapeHtml(item.linha_vinculada_nome)}` : ""
|
||||
}</span>`;
|
||||
} else if (importacaoAtual.status !== "concluida" && PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)) {
|
||||
acaoHtml = `<button type="button" class="btn-outline ips-vincular-btn" data-vincular-auditoria="${item.id}">Vincular pessoa</button>`;
|
||||
@ -2149,13 +2146,13 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
return `
|
||||
<div class="ips-grid-row" role="row">
|
||||
<div class="ips-grid-cell ips-table-actions" role="cell">${acaoHtml}</div>
|
||||
<div class="ips-grid-cell" role="cell">${item.nome || "—"}</div>
|
||||
<div class="ips-grid-cell" role="cell">${item.cpf || "—"}</div>
|
||||
<div class="ips-grid-cell" role="cell">${escapeHtml(item.nome || "—")}</div>
|
||||
<div class="ips-grid-cell" role="cell">${escapeHtml(item.cpf || "—")}</div>
|
||||
<div class="ips-grid-cell" role="cell">${item.tipo === "T" ? "Titular" : "Dependente"}</div>
|
||||
<div class="ips-grid-cell" role="cell">${PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—"}</div>
|
||||
<div class="ips-grid-cell" role="cell">${escapeHtml(PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—")}</div>
|
||||
<div class="ips-grid-cell" role="cell">${Number(item.valor).toFixed(2).replace(".", ",")}</div>
|
||||
<div class="ips-grid-cell" role="cell"><span class="ips-auditoria-motivo">${PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo}</span></div>
|
||||
<div class="ips-grid-cell" role="cell">${item.detalhe || ""}</div>
|
||||
<div class="ips-grid-cell" role="cell"><span class="ips-auditoria-motivo">${escapeHtml(PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo)}</span></div>
|
||||
<div class="ips-grid-cell" role="cell">${escapeHtml(item.detalhe || "")}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@ -2213,8 +2210,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<tr>
|
||||
<td class="ips-table-actions">${acaoHtml}</td>
|
||||
<td>${new Date(alt.criado_em).toLocaleString("pt-BR")}</td>
|
||||
<td><span class="ips-alteracao-tipo ips-alteracao-tipo--${alt.tipo}">${PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo}</span></td>
|
||||
<td>${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"}</td>
|
||||
<td><span class="ips-alteracao-tipo ips-alteracao-tipo--${escapeAttr(alt.tipo)}">${escapeHtml(PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo)}</span></td>
|
||||
<td>${escapeHtml(PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—")}</td>
|
||||
<td>${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"}</td>
|
||||
<td>${detalheHtml}</td>
|
||||
<td>${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"}</td>
|
||||
@ -2247,7 +2244,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
.map((key) => {
|
||||
const html =
|
||||
key === "auditoria" ? panelHtmlAuditoria() : key === "alteracoes" ? panelHtmlAlteracoes() : panelHtmlParaTipo(key);
|
||||
return `<div class="pa-tab-panel" data-ips-tab-panel="${key}" ${key === activeTab ? "" : "hidden"}>${html}</div>`;
|
||||
return `<div class="pa-tab-panel" data-ips-tab-panel="${escapeAttr(key)}" ${key === activeTab ? "" : "hidden"}>${html}</div>`;
|
||||
})
|
||||
.join("");
|
||||
wireColumnResize(panelsEl);
|
||||
@ -2290,8 +2287,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<label class="checklist-item">
|
||||
<input type="radio" name="ips-vincular-linha" value="${l.id}" ${vincularLinhaSelecionada === l.id ? "checked" : ""} />
|
||||
<span class="checklist-item__info">
|
||||
<span class="checklist-item__nome">${nomePrincipal}</span>
|
||||
<span class="checklist-item__departamento">${contexto}</span>
|
||||
<span class="checklist-item__nome">${escapeHtml(nomePrincipal)}</span>
|
||||
<span class="checklist-item__departamento">${escapeHtml(contexto)}</span>
|
||||
</span>
|
||||
</label>`;
|
||||
})
|
||||
|
||||
@ -82,16 +82,14 @@ function pidIndFormatData(iso) {
|
||||
return new Date(iso).toLocaleDateString("pt-BR");
|
||||
}
|
||||
|
||||
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão
|
||||
// antiga por `textContent` não escapava, e a função é usada em atributos).
|
||||
function pidIndEscapeHtml(texto) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = texto == null ? "" : String(texto);
|
||||
return div.innerHTML;
|
||||
return pidEscapeHtml(texto);
|
||||
}
|
||||
|
||||
function pidIndEscapeAttr(texto) {
|
||||
return String(texto == null ? "" : texto)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """);
|
||||
return pidEscapeHtml(texto);
|
||||
}
|
||||
|
||||
// "Cada gerente representa um grupo" (CLAUDE.md): agrupa os colaboradores da
|
||||
@ -417,7 +415,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
tr.innerHTML = `
|
||||
<td>${pidIndFormatCompetencia(apuracao.competencia)}</td>
|
||||
<td>${apuracao.total_colaboradores}</td>
|
||||
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
|
||||
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${pidIndEscapeHtml(PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status)}</span></td>
|
||||
<td>${pidIndEscapeHtml(apuracao.criado_por_nome || "-")}</td>
|
||||
<td>${pidIndFormatData(apuracao.criado_em)}</td>
|
||||
<td class="pa-row-actions">
|
||||
@ -1793,11 +1791,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<tr>
|
||||
<td>${pidIndEscapeHtml(criterio.nome)}</td>
|
||||
<td>${pidIndEscapeHtml(criterio.departamento_nome)}</td>
|
||||
<td>${PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo}</td>
|
||||
<td>${pidIndEscapeHtml(PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo)}</td>
|
||||
<td>${pidIndFormatPercentual(criterio.peso)}</td>
|
||||
<td>${PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo}</td>
|
||||
<td>${pidIndEscapeHtml(PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo)}</td>
|
||||
<td>${criterio.papel_aplicavel ? PID_IND_TIPO_LABELS[criterio.papel_aplicavel] : "Qualquer"}</td>
|
||||
<td>${PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico}</td>
|
||||
<td>${pidIndEscapeHtml(PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico)}</td>
|
||||
<td><span class="status-pill ${criterio.ativo ? "status-pill--ativo" : "status-pill--inativo"}">${criterio.ativo ? "Ativo" : "Inativo"}</span></td>
|
||||
<td class="pa-row-actions">
|
||||
<button type="button" class="icon-btn" data-ind-criterio-toggle="${criterio.id}" title="${criterio.ativo ? "Desativar" : "Ativar"}">
|
||||
@ -1823,7 +1821,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
(percentual) => `
|
||||
<tr>
|
||||
<td>${pidIndEscapeHtml(percentual.departamento_nome)}</td>
|
||||
<td>${percentual.tipo_label}</td>
|
||||
<td>${pidIndEscapeHtml(percentual.tipo_label)}</td>
|
||||
<td>${pidIndFormatPercentual(percentual.percentual_individual)}</td>
|
||||
<td>${pidIndFormatPercentual(percentual.percentual_grupo)}</td>
|
||||
<td>${pidIndFormatPercentual(percentual.percentual_departamento)}</td>
|
||||
|
||||
@ -80,7 +80,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
}
|
||||
|
||||
function cardHtml(link, index) {
|
||||
const iconHtml = link.icone ? `<img class="lf-card__icon" src="${link.icone}" alt="" />` : PID_LINK_DEFAULT_ICON;
|
||||
const iconHtml = link.icone ? `<img class="lf-card__icon" src="${pidEscapeHtml(link.icone)}" alt="" />` : PID_LINK_DEFAULT_ICON;
|
||||
const isFavorite = favoritos.has(link.id);
|
||||
|
||||
const favoriteBtn = `
|
||||
@ -110,9 +110,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
${favoriteBtn}
|
||||
${adminHtml}
|
||||
</div>
|
||||
<a class="lf-card__link" href="${link.url}" target="_blank" rel="noopener noreferrer" draggable="false">
|
||||
<a class="lf-card__link" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" draggable="false">
|
||||
${iconHtml}
|
||||
<span class="lf-card__label">${link.nome}</span>
|
||||
<span class="lf-card__label">${pidEscapeHtml(link.nome)}</span>
|
||||
</a>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@ -21,11 +21,10 @@ function pidNcfFormatDateTime(iso) {
|
||||
return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão
|
||||
// antiga por `textContent` não escapava, e a função é usada em `value="..."`).
|
||||
function pidNcfEscapeHtml(texto) {
|
||||
if (texto == null) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(texto);
|
||||
return div.innerHTML;
|
||||
return pidEscapeHtml(texto);
|
||||
}
|
||||
|
||||
function pidNcfAtualizarContador(el, quantidade) {
|
||||
@ -392,7 +391,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
tr.appendChild(tdBtn);
|
||||
tr.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<td>${oc.codigo}</td>
|
||||
`<td>${pidNcfEscapeHtml(oc.codigo)}</td>
|
||||
<td>${pidNcfFormatDate(oc.data_emissao)}</td>
|
||||
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
|
||||
<td>${pidNcfEscapeHtml(oc.emissor_relato) || "—"}</td>
|
||||
@ -471,7 +470,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
tr.appendChild(tdBtn);
|
||||
tr.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<td>${oc.codigo}</td>
|
||||
`<td>${pidNcfEscapeHtml(oc.codigo)}</td>
|
||||
<td>${pidNcfFormatDate(oc.data_emissao)}</td>
|
||||
<td>${pidNcfEscapeHtml(oc.area) || "—"}</td>
|
||||
<td>${pidNcfEscapeHtml(oc.responsavel_analise) || "—"}</td>
|
||||
@ -525,7 +524,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const detalhe = await pidApiRequest(`/nao-conformidades/ocorrencias/${item.id}/`);
|
||||
const acoesHtml = (detalhe.acoes || []).length
|
||||
? `<h4>Ações vinculadas</h4><ul class="ncf-detail-lista">${detalhe.acoes
|
||||
.map((a) => `<li>${a.ocorrencia_codigo}/${a.codigo} — ${pidNcfEscapeHtml((a.acao_texto || "").slice(0, 80))}</li>`)
|
||||
.map((a) => `<li>${pidNcfEscapeHtml(a.ocorrencia_codigo)}/${pidNcfEscapeHtml(a.codigo)} — ${pidNcfEscapeHtml((a.acao_texto || "").slice(0, 80))}</li>`)
|
||||
.join("")}</ul>`
|
||||
: "";
|
||||
extra.innerHTML = `
|
||||
@ -642,7 +641,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
: "";
|
||||
tr.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<td>${acaoItem.ocorrencia_codigo}/${acaoItem.codigo}</td>
|
||||
`<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_codigo)}/${pidNcfEscapeHtml(acaoItem.codigo)}</td>
|
||||
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_area) || "—"}</td>
|
||||
<td>${pidNcfEscapeHtml(acaoItem.tipo_acao) || "—"}</td>
|
||||
<td>${pidNcfEscapeHtml(acaoItem.ocorrencia_tipo_ocorrencia) || "—"}</td>
|
||||
|
||||
@ -92,15 +92,18 @@ function pidSortEventsByDate(a, b) {
|
||||
return dateCompare !== 0 ? dateCompare : (a.horario || "").localeCompare(b.horario || "");
|
||||
}
|
||||
|
||||
/** `date`/`title`/`breadcrumb` já saem daqui escapados (dado da API) — são
|
||||
* interpolados direto no innerHTML de `render`/`renderHistory`, sem escapar de
|
||||
* novo (o breadcrumb tem `>` fixo de propósito). */
|
||||
function pidMapEventToNotification(ev) {
|
||||
return {
|
||||
id: `event-${ev.id}`,
|
||||
type: "event",
|
||||
date: pidFormatNotifDate(ev.data),
|
||||
title: ev.titulo.toUpperCase(),
|
||||
date: pidEscapeHtml(pidFormatNotifDate(ev.data)),
|
||||
title: pidEscapeHtml(ev.titulo.toUpperCase()),
|
||||
breadcrumb: ev.sou_dono
|
||||
? "Menu > Calendário Individual"
|
||||
: `Menu > Calendário Individual > Compartilhado por ${ev.dono_nome}`,
|
||||
: `Menu > Calendário Individual > Compartilhado por ${pidEscapeHtml(ev.dono_nome)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -204,7 +204,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<tr>
|
||||
<td>${p.codigo}</td>
|
||||
<td>
|
||||
<a href="#" class="pa-name-link" data-pa-edit="${p.codigo}">${p.nome}</a>
|
||||
<a href="#" class="pa-name-link" data-pa-edit="${p.codigo}">${pidEscapeHtml(p.nome)}</a>
|
||||
${p.gerenciaPermissoes ? '<span class="badge-total-access">Acesso total</span>' : ""}
|
||||
</td>
|
||||
<td>
|
||||
@ -229,9 +229,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
moduleListEl.innerHTML = PID_MODULES.map((m, idx) => {
|
||||
const enabled = draftProfile.permissions[m.key].enabled;
|
||||
return `
|
||||
<button type="button" class="pa-module-item ${m.key === activeModuleKey ? "is-active" : ""}" data-pa-module="${m.key}">
|
||||
<button type="button" class="pa-module-item ${m.key === activeModuleKey ? "is-active" : ""}" data-pa-module="${pidEscapeHtml(m.key)}">
|
||||
<span class="pa-module-item__index">${idx + 1}</span>
|
||||
<span class="pa-module-item__label">${m.label}</span>
|
||||
<span class="pa-module-item__label">${pidEscapeHtml(m.label)}</span>
|
||||
<svg class="pa-module-item__check" ${enabled ? "" : "hidden"} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>`;
|
||||
}).join("");
|
||||
@ -241,8 +241,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const match = !term || tool.label.toLowerCase().includes(term);
|
||||
return `
|
||||
<label class="perm-node perm-node--leaf ${match ? "" : "is-hidden"}">
|
||||
<input type="checkbox" data-perm-app="${tool.key}" ${perm.apps[tool.key] ? "checked" : ""} />
|
||||
<span>${tool.label}</span>
|
||||
<input type="checkbox" data-perm-app="${pidEscapeHtml(tool.key)}" ${perm.apps[tool.key] ? "checked" : ""} />
|
||||
<span>${pidEscapeHtml(tool.label)}</span>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
@ -259,12 +259,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
return `
|
||||
<div class="perm-node perm-node--group ${anyMatch ? "" : "is-hidden"}">
|
||||
<button type="button" class="perm-expand ${subgroupExpanded ? "is-open" : ""}" data-perm-subgroup-expand="${subgroupKey}" aria-label="Expandir">
|
||||
<button type="button" class="perm-expand ${subgroupExpanded ? "is-open" : ""}" data-perm-subgroup-expand="${pidEscapeHtml(subgroupKey)}" aria-label="Expandir">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<label>
|
||||
<input type="checkbox" data-perm-group="${entry.key}" ${groupChecked ? "checked" : ""} />
|
||||
<span>${entry.label}</span>
|
||||
<input type="checkbox" data-perm-group="${pidEscapeHtml(entry.key)}" ${groupChecked ? "checked" : ""} />
|
||||
<span>${pidEscapeHtml(entry.label)}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="perm-children perm-children--nested" data-perm-children ${subgroupExpanded ? "" : "hidden"}>
|
||||
@ -295,7 +295,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
}
|
||||
<label>
|
||||
<input type="checkbox" data-perm-role="module" ${perm.enabled ? "checked" : ""} />
|
||||
<span>${moduleLabel}</span>
|
||||
<span>${pidEscapeHtml(moduleLabel)}</span>
|
||||
</label>
|
||||
</div>
|
||||
${
|
||||
@ -339,8 +339,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
(u) => `
|
||||
<tr>
|
||||
<td><input type="checkbox" data-pa-users-select="${u.id}" ${state.selected.has(u.id) ? "checked" : ""} /></td>
|
||||
<td>${u.nome}</td>
|
||||
<td>${u.email || "—"}</td>
|
||||
<td>${pidEscapeHtml(u.nome)}</td>
|
||||
<td>${pidEscapeHtml(u.email || "—")}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
@ -410,9 +410,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
? items
|
||||
.map(
|
||||
(a) => `
|
||||
<button type="button" class="pa-app-result-item" data-pa-app-module="${a.moduleKey}" data-pa-app-key="${a.key}">
|
||||
<span class="pa-app-result-item__label">${a.label}</span>
|
||||
<span class="pa-app-result-item__module">${a.moduleLabel}</span>
|
||||
<button type="button" class="pa-app-result-item" data-pa-app-module="${pidEscapeHtml(a.moduleKey)}" data-pa-app-key="${pidEscapeHtml(a.key)}">
|
||||
<span class="pa-app-result-item__label">${pidEscapeHtml(a.label)}</span>
|
||||
<span class="pa-app-result-item__module">${pidEscapeHtml(a.moduleLabel)}</span>
|
||||
</button>`
|
||||
)
|
||||
.join("")
|
||||
@ -428,7 +428,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
appManageThead.innerHTML = `
|
||||
<tr>
|
||||
<th>Perfil</th>
|
||||
${tools.map((tool) => `<th>${tool.label.split(" (")[0]}</th>`).join("")}
|
||||
${tools.map((tool) => `<th>${pidEscapeHtml(tool.label.split(" (")[0])}</th>`).join("")}
|
||||
</tr>`;
|
||||
|
||||
const rows = profiles
|
||||
@ -442,11 +442,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const perm = p.permissions[entry.moduleKey];
|
||||
return `
|
||||
<tr>
|
||||
<td>${p.nome} ${p.ativo ? "" : '<span class="status-pill status-pill--inativo">Inativo</span>'}</td>
|
||||
<td>${pidEscapeHtml(p.nome)} ${p.ativo ? "" : '<span class="status-pill status-pill--inativo">Inativo</span>'}</td>
|
||||
${tools
|
||||
.map(
|
||||
(tool) => `
|
||||
<td><input type="checkbox" data-pa-app-perfil="${p.codigo}" data-pa-app-key="${tool.key}" ${
|
||||
<td><input type="checkbox" data-pa-app-perfil="${p.codigo}" data-pa-app-key="${pidEscapeHtml(tool.key)}" ${
|
||||
perm.apps[tool.key] ? "checked" : ""
|
||||
} /></td>`
|
||||
)
|
||||
|
||||
@ -36,7 +36,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const botoes = [`<button type="button" class="ram-lookup-depto-btn${activeDepto === "" ? " is-active" : ""}" data-depto="">Todos</button>`].concat(
|
||||
departamentos.map(
|
||||
(d) =>
|
||||
`<button type="button" class="ram-lookup-depto-btn${activeDepto === d.nome ? " is-active" : ""}" data-depto="${d.nome}">${d.nome}</button>`
|
||||
`<button type="button" class="ram-lookup-depto-btn${activeDepto === d.nome ? " is-active" : ""}" data-depto="${pidEscapeHtml(d.nome)}">${pidEscapeHtml(d.nome)}</button>`
|
||||
)
|
||||
);
|
||||
deptoFiltrosEl.innerHTML = botoes.join("");
|
||||
@ -115,9 +115,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
.map(
|
||||
(r) => `
|
||||
<tr>
|
||||
<td>${r.nome_exibicao}</td>
|
||||
<td>${r.departamento_exibicao || "—"}</td>
|
||||
<td>${r.numero_exibicao || "—"}</td>
|
||||
<td>${pidEscapeHtml(r.nome_exibicao)}</td>
|
||||
<td>${pidEscapeHtml(r.departamento_exibicao || "—")}</td>
|
||||
<td>${pidEscapeHtml(r.numero_exibicao || "—")}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
|
||||
@ -232,7 +232,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
let editingId = null;
|
||||
|
||||
function usuarioOptionsHtml() {
|
||||
return usuariosDisponiveis.map((u) => `<option value="${u.id}">${u.nome}</option>`).join("");
|
||||
return usuariosDisponiveis.map((u) => `<option value="${pidEscapeHtml(u.id)}">${pidEscapeHtml(u.nome)}</option>`).join("");
|
||||
}
|
||||
|
||||
function rowHtml(ramal) {
|
||||
@ -241,11 +241,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const rowClass = aniversario ? "ram-row--aniversario" : ausente ? "ram-row--ausente" : "";
|
||||
|
||||
const nomeHtml = aniversario
|
||||
? `${ramal.nome_exibicao} <span class="ram-badge ram-badge--aniversario">🎉 Feliz Aniversário</span>`
|
||||
: ramal.nome_exibicao;
|
||||
? `${pidEscapeHtml(ramal.nome_exibicao)} <span class="ram-badge ram-badge--aniversario">🎉 Feliz Aniversário</span>`
|
||||
: pidEscapeHtml(ramal.nome_exibicao);
|
||||
|
||||
const numeroHtml = ramal.numero_exibicao
|
||||
? ramal.numero_exibicao
|
||||
? pidEscapeHtml(ramal.numero_exibicao)
|
||||
: `<span class="ram-badge ram-badge--pendente">Pendente</span>`;
|
||||
|
||||
const ausenteBadgeHtml = ausente
|
||||
@ -270,7 +270,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${nomeHtml}</td>
|
||||
<td>${ramal.departamento_exibicao || "—"}</td>
|
||||
<td>${pidEscapeHtml(ramal.departamento_exibicao || "—")}</td>
|
||||
<td>${ramalHtml}</td>
|
||||
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
|
||||
</tr>`;
|
||||
@ -317,10 +317,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${tel.nome}</td>
|
||||
<td>${tel.ramal || "—"}</td>
|
||||
<td>${tel.telefone || "—"}</td>
|
||||
<td>${tel.observacoes || "—"}</td>
|
||||
<td>${pidEscapeHtml(tel.nome)}</td>
|
||||
<td>${pidEscapeHtml(tel.ramal || "—")}</td>
|
||||
<td>${pidEscapeHtml(tel.telefone || "—")}</td>
|
||||
<td>${pidEscapeHtml(tel.observacoes || "—")}</td>
|
||||
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
|
||||
</tr>`;
|
||||
}
|
||||
@ -379,9 +379,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${fun.comando}</td>
|
||||
<td>${fun.funcao}</td>
|
||||
<td>${fun.resumo || "—"}</td>
|
||||
<td>${pidEscapeHtml(fun.comando)}</td>
|
||||
<td>${pidEscapeHtml(fun.funcao)}</td>
|
||||
<td>${pidEscapeHtml(fun.resumo || "—")}</td>
|
||||
<td><div class="pa-row-actions">${editBtnHtml}${deleteBtnHtml}</div></td>
|
||||
</tr>`;
|
||||
}
|
||||
@ -480,7 +480,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
}
|
||||
|
||||
function fillAusenciaViewFields(ausencia, ramal) {
|
||||
ausViewUsuario.innerHTML = `<option>${ramal.nome_exibicao} - ${ramal.departamento_exibicao || "—"} / Ramal: ${ramal.numero_exibicao || "—"}</option>`;
|
||||
ausViewUsuario.innerHTML = `<option>${pidEscapeHtml(ramal.nome_exibicao)} - ${pidEscapeHtml(ramal.departamento_exibicao || "—")} / Ramal: ${pidEscapeHtml(ramal.numero_exibicao || "—")}</option>`;
|
||||
ausViewDataInicio.value = ausencia.data_inicio || "";
|
||||
ausViewHoraInicio.value = ausencia.hora_inicio ? ausencia.hora_inicio.slice(0, 5) : "";
|
||||
ausViewDataFim.value = ausencia.data_fim || "";
|
||||
|
||||
@ -120,7 +120,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Célula vazia fica destacada (itálico, cor apagada) — ordenar a coluna
|
||||
// agrupa quem ainda não tem aquele código/ramal cadastrado.
|
||||
function celulaCodigo(valor) {
|
||||
return valor ? valor : '<span class="ua-campo-vazio">—</span>';
|
||||
return valor ? pidEscapeHtml(valor) : '<span class="ua-campo-vazio">—</span>';
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
@ -143,14 +143,14 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
.map(
|
||||
(u) => `
|
||||
<tr>
|
||||
<td>${u.username}</td>
|
||||
<td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${u.nome}</a></td>
|
||||
<td>${pidEscapeHtml(u.username)}</td>
|
||||
<td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${pidEscapeHtml(u.nome)}</a></td>
|
||||
<td>${celulaCodigo(u.codigo_folha)}</td>
|
||||
<td>${celulaCodigo(u.codigo_questor)}</td>
|
||||
<td>${celulaCodigo(u.codigo_tareffa)}</td>
|
||||
<td>${celulaCodigo(u.codigo_contabit)}</td>
|
||||
<td>${celulaCodigo(u.ramal)}</td>
|
||||
<td>${profileNomes(u.perfis)}</td>
|
||||
<td>${pidEscapeHtml(profileNomes(u.perfis))}</td>
|
||||
<td><span class="status-pill ${u.is_active ? "status-pill--ativo" : "status-pill--inativo"}">${u.is_active ? "Ativo" : "Inativo"}</span></td>
|
||||
<td>
|
||||
<div class="pa-row-actions">
|
||||
@ -180,7 +180,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
(p) => `
|
||||
<label class="checklist-item">
|
||||
<input type="checkbox" value="${p.codigo}" ${selectedCodigos.includes(p.codigo) ? "checked" : ""} />
|
||||
<span>${p.nome}</span>
|
||||
<span>${pidEscapeHtml(p.nome)}</span>
|
||||
</label>`
|
||||
)
|
||||
.join("");
|
||||
@ -211,7 +211,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
<div class="ua-departamento-item">
|
||||
<label class="checklist-item">
|
||||
<input type="checkbox" value="${d.id}" ${selectedIds.includes(d.id) ? "checked" : ""} />
|
||||
<span>${d.nome}</span>
|
||||
<span>${pidEscapeHtml(d.nome)}</span>
|
||||
</label>
|
||||
<div class="ua-departamento-item__actions">
|
||||
<button type="button" class="ua-departamento-action" data-dep-edit="${d.id}" aria-label="Renomear departamento">
|
||||
|
||||
@ -80,9 +80,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const dateLabel = horario ? `${day}/${month} ${horario}` : `${day}/${month}`;
|
||||
return `
|
||||
<div class="widget-agenda-row">
|
||||
<span class="widget-agenda-row__date">${dateLabel}</span>
|
||||
<span class="widget-agenda-row__title">${ev.titulo}</span>
|
||||
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${ev.dono_nome}</span>` : ""}
|
||||
<span class="widget-agenda-row__date">${pidEscapeHtml(dateLabel)}</span>
|
||||
<span class="widget-agenda-row__title">${pidEscapeHtml(ev.titulo)}</span>
|
||||
${!ev.sou_dono ? `<span class="widget-agenda-row__owner">— ${pidEscapeHtml(ev.dono_nome)}</span>` : ""}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
@ -101,12 +101,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
${favoritos
|
||||
.map((link) => {
|
||||
const iconHtml = link.icone
|
||||
? `<img class="widget-links-list__icon" src="${link.icone}" alt="" />`
|
||||
? `<img class="widget-links-list__icon" src="${pidEscapeHtml(link.icone)}" alt="" />`
|
||||
: PID_WIDGET_LINK_DEFAULT_ICON;
|
||||
return `
|
||||
<a class="widget-links-list__row" href="${link.url}" target="_blank" rel="noopener noreferrer">
|
||||
<a class="widget-links-list__row" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">
|
||||
${iconHtml}
|
||||
<span>${link.nome}</span>
|
||||
<span>${pidEscapeHtml(link.nome)}</span>
|
||||
</a>`;
|
||||
})
|
||||
.join("")}
|
||||
@ -133,7 +133,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const height = widget.altura || PID_WIDGET_DEFAULT_HEIGHT;
|
||||
const body = widgetBodyCache[widget.tipo] || "";
|
||||
return `
|
||||
<article class="widget-card" data-widget-id="${widget.tipo}" style="width:${width}px;height:${height}px;">
|
||||
<article class="widget-card" data-widget-id="${widget.tipo}" style="width:${pidEscapeHtml(width)}px;height:${pidEscapeHtml(height)}px;">
|
||||
<div class="widget-card__header" draggable="true">
|
||||
<h3>${type.label}</h3>
|
||||
<button type="button" class="widget-card__remove" data-widget-remove="${widget.tipo}" aria-label="Remover widget" draggable="false">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user