Inclusão da linha de total dos valores de plano de saúde
This commit is contained in:
parent
f826d73d2a
commit
8942907947
@ -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.
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user