Inclusão da linha de total dos valores de plano de saúde

This commit is contained in:
Gabriel 2026-08-27 15:39:35 -03:00
parent f826d73d2a
commit 8942907947
3 changed files with 123 additions and 14 deletions

View File

@ -62,6 +62,10 @@ Pra adicionar uma operadora nova: criar `operadoras/<nome>/<arquivo>.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.

View File

@ -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). */

View File

@ -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 () => {
</div>`
: "";
// 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
? `<div class="ips-grid-row ips-grid-row--totais" role="row">
<div class="ips-grid-cell ips-table-actions" role="cell"></div>
${campos
.map(([campo]) => {
if (campo === "nome_func") return `<div class="ips-grid-cell" role="cell">Total</div>`;
if (campo === "valor_empresa" || campo === "valor") {
const total = campo === "valor_empresa" ? totalValorEmpresa : totalValor;
return `<div class="ips-grid-cell" role="cell">${formatarNumeroBr(total)}</div>`;
}
return `<div class="ips-grid-cell" role="cell"></div>`;
})
.join("")}
</div>`
: "";
const linhasHtml = linhas
.map((linha) => {
const editavelLivre = !concluida && incluidasManualmente.has(linha.id);
@ -1884,6 +1963,7 @@ document.addEventListener("DOMContentLoaded", async () => {
linhasHtml ||
`<div class="ips-grid-row pa-empty-row" role="row"><div class="ips-grid-cell" role="cell" style="grid-column:1 / -1">Nenhuma linha.</div></div>`
}
${totaisHtml}
</div>
</div>
${concluida ? "" : `
@ -2032,6 +2112,7 @@ document.addEventListener("DOMContentLoaded", async () => {
})
.join("");
wireColumnResize(panelsEl);
wireAutoExpandScroll(panelsEl);
}
// ------------------------------------------------------------------