From 894290794795cc12abdbf36a298a225a88960c0d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Aug 2026 15:39:35 -0300 Subject: [PATCH] =?UTF-8?q?Inclus=C3=A3o=20da=20linha=20de=20total=20dos?= =?UTF-8?q?=20valores=20de=20plano=20de=20sa=C3=BAde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal_api/planos_saude/CLAUDE.md | 4 ++ static/css/importacao-plano-saude.css | 36 ++++++++-- static/js/importacao-plano-saude.js | 97 ++++++++++++++++++++++++--- 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/portal_api/planos_saude/CLAUDE.md b/portal_api/planos_saude/CLAUDE.md index d492a61..65b2049 100644 --- a/portal_api/planos_saude/CLAUDE.md +++ b/portal_api/planos_saude/CLAUDE.md @@ -62,6 +62,10 @@ Pra adicionar uma operadora nova: criar `operadoras//.py` impleme - `ImportacaoPlanoSaudeLinha`: uma linha da planilha padrão já casada com o valor do mês (espelha `LinhaSistema` campo a campo) — na tela de revisão, uma linha que já veio do processamento (upload ou Questor) só edita **Valor Empresa/Valor**; os demais campos (cadastro da pessoa) só ficam editáveis numa linha incluída manualmente via "Adicionar linha" (regra revista — nasceu como "todos os campos editáveis em qualquer linha", decisão do usuário depois de ver dados reais na tela: só uma linha nova precisa editar o cadastro, uma linha já casada não devia arriscar um cadastro certo sendo alterado por engano). Essa restrição é só de UI (`importacao-plano-saude.js`, `linhasIncluidasManualmente()` — deriva de `ImportacaoPlanoSaudeAlteracao` já carregada, sem campo novo), o backend continua aceitando PATCH em qualquer campo. `valor`/`valor_empresa` ficam como `CharField` no mesmo formato string do pipeline (`"51,69"`/`"0"`), não `DecimalField`, pra manter fidelidade 1:1 com o CSV final sem risco de arredondamento. - **Expressão de soma/subtração na célula** (pedido explícito do usuário): ao sair de uma célula de Valor/Valor Empresa (`change`), `pidAvaliaExpressaoValorMonetario()` (`importacao-plano-saude.js`) reconhece se o que foi digitado é uma expressão com `+`/`-` entre números em formato BR (ex.: `"15,30-15"` → `"0,30"`) e substitui o campo pelo resultado antes de mandar o PATCH — puramente client-side, o backend nunca recebe a expressão, só o valor já calculado (continua sem nenhuma validação de formato numérico nesse `CharField`, como já era). Um valor negativo digitado direto (ex.: `"-15,30"`, sem operador depois do primeiro caractere) não é tratado como expressão, continua sendo só um número negativo literal. - **Edição de célula (Valor/Valor Empresa) refaz o fetch da importação inteira e re-renderiza** (`renderTabs()`) depois do PATCH — bug real corrigido (2026-08-26): antes disso, `importacaoAtual` só era atualizado por outras ações da revisão (adicionar/remover linha, vincular pessoa, reverter alteração), então editar uma célula deixava o resumo por tipo (contadores "linhas no total"/"com valor lançado"/"em auditoria") e a aba Alterações com o estado de antes da edição até o usuário sair e reabrir a importação do zero. + - **Linha de total no rodapé da tabela** (pedido explícito do usuário): as abas de Mensalidade/Coparticipação (`panelHtmlParaTipo()`, `importacao-plano-saude.js`) ganharam uma última linha (`.ips-grid-row--totais`, fundo/negrito iguais ao cabeçalho) somando Valor Empresa e Valor de **todas** as linhas do tipo (não só as visíveis num scroll, e independente da ordenação da coluna) — rótulo "Total" fica na coluna "Nome Funcionário", as demais colunas ficam em branco. `pidValorRevisaoParaNumero()` (extraída de `comparaValorRevisao()`, mesma função reaproveitada) converte cada `"1.234,56"` BR pra número antes de somar; linhas em auditoria (valor "0") entram na soma sem alterá-la. Some sozinha quando a aba está vazia (mesmo `linhas.length` que já decide a linha "Nenhuma linha."), não é uma linha nova editável nem gera `ImportacaoPlanoSaudeAlteracao`. + - **Fixa no fim da área visível** (`importacao-plano-saude.css`, pedido explícito do usuário): `.ips-grid-row--totais .ips-grid-cell` usa `position:sticky; bottom:0` — gruda no rodapé de `.ips-table-scroll` (o ancestral com `overflow-y`) independente de até onde o usuário rolou a tabela ou de quanto ela foi expandida (`wireAutoExpandScroll()`, ver abaixo — sticky é recalculado contra o tamanho atual do container, não um valor fixo em px). `z-index:2` (maior que o `1` da coluna de ações comum) garante que a linha continue por cima das linhas de dado que passam por trás dela ao rolar. + - **Alça de redimensionar só de altura, não mais de largura** (`.ips-table-scroll`, `importacao-plano-saude.css`): caixa de cada aba (Mensalidade/Coparticipação/Auditoria/Alterações) nasceu com `resize:both` (420px de altura inicial, até 80vh), chegou a virar altura totalmente livre (sem caixa/scroll interno, uma rodada revertida) e voltou pra caixa de 420px — só que agora com `resize:vertical` em vez de `resize:both`: o usuário só arrasta a alça pra aumentar a altura (ver mais linhas de uma vez), a largura já rola sozinha via `overflow-x:auto`, sem precisar de alça própria pra isso. O redimensionar de **coluna** (`wireColumnResize()`, alça em cada cabeçalho) é outro mecanismo, independente, e não foi afetado por nenhuma dessas mudanças. + - **Duplo clique na alça alterna expandir/voltar** (`wireAutoExpandScroll()`, `importacao-plano-saude.js`, chamada junto de `wireColumnResize()` em `renderPanels()`): a alça nativa do `resize` não é um elemento do DOM, então o duplo clique é reconhecido pela posição do clique dentro dos últimos ~20px do canto inferior direito de `.ips-table-scroll` (`PID_IPS_RESIZE_HANDLE_HIT_PX`), não por um alvo específico. Primeiro duplo clique: `wrap.style.maxHeight="none"` (senão o teto de 80vh continuaria cortando) e `wrap.style.height = wrap.scrollHeight + "px"` — mesma ideia de "autofit" de largura de coluna de uma planilha (Excel/Sheets), só que de altura; `wrap.dataset.autoExpandido` marca o estado, guardando o `height`/`max-height` inline de antes (`dataset.alturaAnterior`/`maxAlturaAnterior`, string vazia = "sem inline style", volta a valer o CSS padrão). Segundo duplo clique: restaura exatamente esses dois valores guardados (pedido explícito do usuário — "reverter para como estava antes", não necessariamente os 420px padrão, caso o usuário já tivesse arrastado a alça manualmente antes de expandir) e limpa o estado. Mesma limitação do redimensionamento manual por arrasto: é um ajuste inline, `renderPanels()` recria os elementos do zero (qualquer edição de célula, adicionar/remover linha etc.), então sempre nasce não-expandido de novo. - `ImportacaoPlanoSaudeAuditoria`: espelha `ItemAuditoria` — os campos extraídos do arquivo da operadora (`motivo`/`nome`/`valor`/`detalhe`...) são read-only na tela; `resolvida`/`linha_vinculada` são a exceção, graváveis via a resolução manual (ver "Resolução manual de auditoria por nome" abaixo). `MOTIVOS_RESOLVIVEIS = ("NOME_DIVERGENTE", "NAO_CADASTRADO")` (atributo de classe) é a lista dos dois motivos "de leitura/grafia de nome" que aceitam esse fluxo — `VALOR_NEGATIVO`/`TIPO_INVALIDO` são outra categoria de problema (valor real negativo, tipo de despesa não mapeado) e não têm solução por "essa é a mesma pessoa". - `ImportacaoPlanoSaudeAlteracao`: log de cada edição de campo/inclusão/exclusão de linha feita manualmente na revisão — ver seção "Alterações" abaixo. - `RegraCusteioPlanoSaude`: regra de custeio salva pra reaplicar em importações futuras (ex.: "092 - Unimed") — ver "Regras de custeio salvas" abaixo. Lista compartilhada, mesmo espírito de `LinkFerramenta`/`AcessoGeral` — o próprio model não tem FK pra nada; é `ImportacaoPlanoSaude.regra_custeio_salva` que aponta pra cá (opcional, `SET_NULL`), só como registro de qual regra (se alguma) foi aplicada pra preencher aquele formulário. diff --git a/static/css/importacao-plano-saude.css b/static/css/importacao-plano-saude.css index b0bc6bb..fc8d042 100644 --- a/static/css/importacao-plano-saude.css +++ b/static/css/importacao-plano-saude.css @@ -717,12 +717,12 @@ min-height: 160px; max-height: 80vh; max-width: 100%; - /* `resize` exige overflow != visible pra mostrar a alça de redimensionar no - canto inferior direito — o usuário arrasta pra ver mais linhas (altura) e - mais colunas de uma vez (largura), até 100% da largura de .page-content - (que aqui é .page-content--wide, 1600px em vez do padrão 1200px, ver - layout.css). */ - resize: both; + /* `resize:vertical` (só altura, não largura — pedido explícito do usuário) + exige overflow != visible pra mostrar a alça de redimensionar no canto + inferior direito — o usuário arrasta só pra baixo, pra ver mais linhas de + uma vez, até 80vh. Largura já rola sozinha via overflow-x (abaixo), não + precisa de alça própria. */ + resize: vertical; overflow-y: auto; /* .pa-table-wrap (perfis-acesso.css) tem overflow:hidden, que corta colunas em vez de rolar quando a tabela é mais larga que o container — sobrescreve @@ -821,6 +821,30 @@ padding: var(--space-6); } +/* Linha de total (Valor Empresa/Valor) no rodapé de Mensalidade/Coparticipação + — mesmo fundo do cabeçalho (.ips-grid-cell--head), pra ler como um resumo + fixo, não mais uma linha de dado igual às demais. `position:sticky; + bottom:0` (pedido explícito do usuário) gruda a linha no fim da área + visível de `.ips-table-scroll` (o ancestral com overflow-y:auto/scroll) — + fica sempre à vista rolando a tabela pra cima/baixo, e continua funcionando + igual com a tabela expandida (`wireAutoExpandScroll()`, JS), já que sticky + é recalculado contra o tamanho atual do container, não um valor fixo. + z-index maior que o da coluna de ações comum (1) garante que a linha de + total fique por cima das linhas de dado ao rolar por trás dela. */ +.ips-grid-row--totais .ips-grid-cell { + position: sticky; + bottom: 0; + z-index: 2; + font-weight: 700; + color: var(--text-primary); + background: var(--bg-surface-raised); + border-top: 1px solid var(--border-subtle); +} + +.ips-grid-row--totais .ips-grid-cell.ips-table-actions { + background: var(--bg-surface-raised); +} + /* Coluna de ações (ícone fixo) não recebe alça de redimensionar, então fica de fora do `position: relative` — evita conflitar com o `position: sticky` dela (ver regra mais abaixo). */ diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index 9ad729b..d13dfb3 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -412,19 +412,24 @@ document.addEventListener("DOMContentLoaded", async () => { let regracadModoNovo = false; let regracadNovoOperadoraKey = null; + // "1.234,56" (BR) -> 1234.56; qualquer coisa que não seja só dígitos/./, + // (nome, CPF com hífen, data dd/mm/aaaa) -> NaN. Reaproveitada por + // comparaValorRevisao() (ordenação) e por totaisLinhaHtml() (soma da linha + // de total no rodapé da tabela). + function pidValorRevisaoParaNumero(valor) { + if (valor === null || valor === undefined || valor === "") return NaN; + const texto = String(valor).trim(); + if (!/^-?[\d.,]+$/.test(texto)) return NaN; + return Number(texto.replace(/\./g, "").replace(",", ".")); + } + // Compara valores de uma célula da tabela de revisão pra ordenação por // coluna — números no formato BR (milhar com ponto, decimal com vírgula, // ex.: "1.234,56") são comparados numericamente; qualquer outra coisa // (nome, CPF com hífen, data dd/mm/aaaa) cai pra comparação de texto. function comparaValorRevisao(a, b) { - const paraNumero = (valor) => { - if (valor === null || valor === undefined || valor === "") return NaN; - const texto = String(valor).trim(); - if (!/^-?[\d.,]+$/.test(texto)) return NaN; - return Number(texto.replace(/\./g, "").replace(",", ".")); - }; - const an = paraNumero(a); - const bn = paraNumero(b); + const an = pidValorRevisaoParaNumero(a); + const bn = pidValorRevisaoParaNumero(b); if (!Number.isNaN(an) && !Number.isNaN(bn)) return an - bn; return String(a || "").localeCompare(String(b || ""), "pt-BR", { sensitivity: "base" }); } @@ -1806,6 +1811,57 @@ document.addEventListener("DOMContentLoaded", async () => { }); } + // Tamanho aproximado (px) da área que o navegador reserva no canto + // inferior direito de um elemento com `resize` pra desenhar a própria alça + // de arrastar — não é um elemento do DOM (não dá pra selecionar/escutar + // direto nela), então um duplo clique é reconhecido só pela posição do + // clique dentro do container, não por um `target` específico. + const PID_IPS_RESIZE_HANDLE_HIT_PX = 20; + + // Duplo clique na alça de redimensionar (canto inferior direito de + // `.ips-table-scroll`, `resize:vertical`) alterna entre expandir a altura + // pra caber TODAS as linhas de uma vez (pedido explícito do usuário) e + // voltar exatamente pra como estava antes de expandir — um segundo duplo + // clique não é "voltar ao padrão de 420px", é desfazer a expansão em cima + // do que quer que já estivesse (padrão, ou uma altura já arrastada à mão + // antes do duplo clique). `wrap.dataset.autoExpandido` marca o estado + // atual; `alturaAnterior`/`maxAlturaAnterior` guardam o inline style de + // antes de expandir (string vazia = "sem inline style", volta a valer o + // CSS de `.ips-table-scroll`), restaurado ao alternar de volta. + // `scrollHeight` já inclui o conteúdo que hoje transborda pro scroll + // interno; `max-height` (80vh, `importacao-plano-saude.css`) precisa ser + // removido enquanto expandido, senão continuaria cortando a tabela numa + // importação com muitas linhas. Como o redimensionamento manual por + // arrasto, todo esse estado é só inline/local: `renderPanels()` recria os + // elementos do zero (qualquer edição de célula, adicionar/remover linha + // etc.), então volta a nascer não-expandido. + function wireAutoExpandScroll(root) { + root.querySelectorAll(".ips-table-scroll").forEach((wrap) => { + wrap.addEventListener("dblclick", (event) => { + const rect = wrap.getBoundingClientRect(); + const noCantoDaAlca = + event.clientX >= rect.right - PID_IPS_RESIZE_HANDLE_HIT_PX && + event.clientY >= rect.bottom - PID_IPS_RESIZE_HANDLE_HIT_PX; + if (!noCantoDaAlca) return; + + if (wrap.dataset.autoExpandido === "true") { + wrap.style.height = wrap.dataset.alturaAnterior || ""; + wrap.style.maxHeight = wrap.dataset.maxAlturaAnterior || ""; + delete wrap.dataset.autoExpandido; + delete wrap.dataset.alturaAnterior; + delete wrap.dataset.maxAlturaAnterior; + return; + } + + wrap.dataset.alturaAnterior = wrap.style.height; + wrap.dataset.maxAlturaAnterior = wrap.style.maxHeight; + wrap.style.maxHeight = "none"; + wrap.style.height = `${wrap.scrollHeight}px`; + wrap.dataset.autoExpandido = "true"; + }); + }); + } + // Colunas sempre editáveis, em qualquer linha — o resto do cadastro // (nome, CPF, código, data...) já vem certo do casamento automático (ou // da busca no Questor) e só devia ser tocado numa linha incluída à mão @@ -1846,6 +1902,29 @@ document.addEventListener("DOMContentLoaded", async () => { ` : ""; + // Linha de total no rodapé da tabela (pedido explícito do usuário) — soma + // Valor Empresa/Valor de TODAS as linhas do tipo (`linhas`, já filtrada + // por tipo_lancamento acima), inclusive as ainda em auditoria (valor "0", + // não altera a soma). Ordenação da tabela não afeta o total, é sempre a + // soma do conjunto inteiro, não só das linhas visíveis num scroll. + const totalValorEmpresa = linhas.reduce((soma, l) => soma + (pidValorRevisaoParaNumero(l.valor_empresa) || 0), 0); + const totalValor = linhas.reduce((soma, l) => soma + (pidValorRevisaoParaNumero(l.valor) || 0), 0); + const totaisHtml = linhas.length + ? `
+
+ ${campos + .map(([campo]) => { + if (campo === "nome_func") return `
Total
`; + if (campo === "valor_empresa" || campo === "valor") { + const total = campo === "valor_empresa" ? totalValorEmpresa : totalValor; + return `
${formatarNumeroBr(total)}
`; + } + return `
`; + }) + .join("")} +
` + : ""; + const linhasHtml = linhas .map((linha) => { const editavelLivre = !concluida && incluidasManualmente.has(linha.id); @@ -1884,6 +1963,7 @@ document.addEventListener("DOMContentLoaded", async () => { linhasHtml || `
Nenhuma linha.
` } + ${totaisHtml} ${concluida ? "" : ` @@ -2032,6 +2112,7 @@ document.addEventListener("DOMContentLoaded", async () => { }) .join(""); wireColumnResize(panelsEl); + wireAutoExpandScroll(panelsEl); } // ------------------------------------------------------------------