const PID_IPS_MOTIVO_LABELS = { VALOR_NEGATIVO: "Valor negativo no mês", NAO_CADASTRADO: "Não cadastrado no sistema", NOME_DIVERGENTE: "Nome divergente", TIPO_INVALIDO: "Tipo de despesa não reconhecido", }; // Espelha ImportacaoPlanoSaudeAuditoria.MOTIVOS_RESOLVIVEIS (models.py) — só // esses motivos são "problema de leitura/grafia de nome", então só eles // ganham o botão "Vincular pessoa" na aba Auditoria. const PID_IPS_MOTIVOS_RESOLVIVEIS = ["NOME_DIVERGENTE", "NAO_CADASTRADO"]; const PID_IPS_TIPO_LABELS = { mensalidade: "Mensalidade", coparticipacao: "Coparticipação", }; const PID_IPS_TIPOS = ["mensalidade", "coparticipacao"]; const PID_IPS_PESSOAS = ["titular", "dependente"]; const PID_IPS_PESSOA_LABELS = { titular: "Titular", dependente: "Dependente", }; // Larguras padrão ajustadas (pedido explícito do usuário) pra caber cada // rótulo do cabeçalho sem cortar, sem precisar redimensionar manualmente // toda vez que abre uma importação — continuam ajustáveis pela alça de // redimensionar (colWidthsLinha, ver wireColumnResize), isso só muda o // ponto de partida. const PID_IPS_CAMPOS_LINHA = [ ["codigo_empresa", "Cód. Empresa", 110], ["nome_func", "Nome Funcionário", 210], ["cpf_func", "CPF Funcionário", 160], ["codigo_out_emp", "Cód. Out. Emp.", 140], ["data_inicial", "Data Inicial", 140], ["nome_dependente", "Nome Dependente", 210], ["cpf_dependente", "CPF Dependente", 160], ["valor_empresa", "Valor Empresa", 130], ["valor", "Valor", 100], ["descricao", "Descrição", 240], ]; // Lookup campo -> label pra aba "Alterações" (ex.: "valor" -> "Valor"), // derivado de PID_IPS_CAMPOS_LINHA pra nunca divergir do rótulo já usado na // própria coluna da tabela de Mensalidade/Coparticipação. const PID_IPS_CAMPO_LABELS = Object.fromEntries(PID_IPS_CAMPOS_LINHA.map(([campo, label]) => [campo, label])); // Só os dois campos monetários aceitam expressão de soma/subtração digitada // na própria célula (ex.: "15,30-15" -> "0,30") — pedido explícito do // usuário, pra abater/somar um valor sem precisar calcular o resultado à // parte antes de digitar. Nome/CPF/data/descrição nunca passam por aqui. const PID_IPS_CAMPOS_EXPRESSAO_VALOR = new Set(["valor_empresa", "valor"]); // Devolve o valor calculado (formato BR, "0,30") se `texto` for uma // expressão reconhecível de soma/subtração entre números BR (vírgula // decimal, ponto de milhar opcional), ou `null` se não for — nesse caso o // valor digitado segue para o servidor exatamente como está, sem alteração // nenhuma (mesmo comportamento de antes desta função existir). // // Um único número com sinal de abertura (ex.: "-15,30") NÃO conta como // expressão — só quando há pelo menos um operador +/- depois do primeiro // caractere, senão todo valor negativo digitado direto viraria "0" (soma de // um termo só). Qualquer caractere que sobre fora dos termos reconhecidos // (letra, símbolo, dois operadores seguidos...) também devolve `null`, pra // nunca "adivinhar" um cálculo a partir de um valor mal digitado. function pidAvaliaExpressaoValorMonetario(texto) { const raw = (texto || "").trim(); if (!raw || raw.slice(1).search(/[+-]/) === -1) return null; const semEspacos = raw.replace(/\s+/g, ""); const termos = semEspacos.match(/[+-]?\d+(?:\.\d{3})*(?:,\d+)?/g); if (!termos || termos.join("") !== semEspacos) return null; const soma = termos.reduce((total, termo) => { const sinal = termo.startsWith("-") ? -1 : 1; const numero = termo.replace(/^[+-]/, "").replace(/\./g, "").replace(",", "."); return total + sinal * parseFloat(numero); }, 0); if (!Number.isFinite(soma)) return null; return soma === 0 ? "0" : soma.toFixed(2).replace(".", ","); } // Espelha ImportacaoPlanoSaudeAlteracao.TIPO_CHOICES (models.py). const PID_IPS_ALTERACAO_TIPO_LABELS = { edicao: "Edição", inclusao: "Inclusão", exclusao: "Exclusão", vinculo_automatico: "Vínculo automático", }; const PID_IPS_CAMPOS_AUDITORIA = [ ["nome", "Nome", 160], ["cpf", "CPF", 120], ["tipo", "Tipo", 100], ["tipo_lancamento", "Lançamento", 120], ["valor", "Valor", 100], ["motivo", "Motivo", 180], ["detalhe", "Justificativa", 320], ]; const PID_IPS_AUDITORIA_ACAO_WIDTH = 160; const PID_IPS_COL_MIN_WIDTH = 60; async function pidFetchImportacoesPlanoSaude() { return (await pidApiRequest("/importacoes-plano-saude/")) || []; } async function pidFetchImportacaoPlanoSaude(id) { return pidApiRequest(`/importacoes-plano-saude/${id}/`); } async function pidFetchOperadorasPlanoSaude() { return (await pidApiRequest("/importacoes-plano-saude/operadoras/")) || []; } async function pidFetchRegrasEmpresaPlanoSaude() { return (await pidApiRequest("/importacoes-plano-saude/regras-empresa/")) || []; } async function pidExcluirImportacaoPlanoSaude(id) { return pidApiRequest(`/importacoes-plano-saude/${id}/`, { method: "DELETE" }); } async function pidCriarImportacaoPlanoSaude(formData) { return pidApiRequest("/importacoes-plano-saude/", { method: "POST", body: formData }); } async function pidReabrirImportacaoPlanoSaude(id) { return pidApiRequest(`/importacoes-plano-saude/${id}/reabrir/`, { method: "POST" }); } async function pidValidarArquivoPlanoSaude(tipo, arquivo, operadoraKey) { const formData = new FormData(); formData.append("tipo", tipo); formData.append("arquivo", arquivo); if (operadoraKey) formData.append("operadora", operadoraKey); return pidApiRequest("/importacoes-plano-saude/validar-arquivo/", { method: "POST", body: formData }); } async function pidAtualizarLinhaPlanoSaude(id, campos) { return pidApiRequest(`/importacoes-plano-saude-linhas/${id}/`, { method: "PATCH", body: campos }); } async function pidCriarLinhaPlanoSaude(importacaoId, tipoLancamento) { return pidApiRequest("/importacoes-plano-saude-linhas/", { method: "POST", body: { importacao: importacaoId, tipo_lancamento: tipoLancamento }, }); } async function pidExcluirLinhaPlanoSaude(id) { return pidApiRequest(`/importacoes-plano-saude-linhas/${id}/`, { method: "DELETE" }); } async function pidResolverAuditoriaPlanoSaude(itemId, linhaId) { return pidApiRequest(`/importacoes-plano-saude-auditoria/${itemId}/resolver/`, { method: "POST", body: { linha_id: linhaId }, }); } async function pidReverterAlteracaoPlanoSaude(id) { return pidApiRequest(`/importacoes-plano-saude-alteracoes/${id}/reverter/`, { method: "POST" }); } async function pidFetchRegrasCusteioPlanoSaude() { return (await pidApiRequest("/regras-custeio-plano-saude/")) || []; } async function pidCriarRegraCusteioPlanoSaude(dados) { return pidApiRequest("/regras-custeio-plano-saude/", { method: "POST", body: dados }); } async function pidAtualizarRegraCusteioPlanoSaude(id, dados) { return pidApiRequest(`/regras-custeio-plano-saude/${id}/`, { method: "PATCH", body: dados }); } async function pidExcluirRegraCusteioPlanoSaude(id) { return pidApiRequest(`/regras-custeio-plano-saude/${id}/`, { method: "DELETE" }); } async function pidBuscarNomeEmpresaPlanoSaude(codigoEmpresa) { return pidApiRequest(`/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=${encodeURIComponent(codigoEmpresa)}`); } /** * Resposta de /gerar/ é binária (CSV ou ZIP), não JSON — por isso não passa * por pidApiRequest. Reaproveita pidEnsureCsrfCookie/pidGetCookie/pidErrorMessageFrom * de api.js (globais na página) pra manter o mesmo tratamento de CSRF/erro. */ async function pidGerarArquivoPlanoSaude(id) { await pidEnsureCsrfCookie(); const response = await fetch(`/api/importacoes-plano-saude/${id}/gerar/`, { method: "POST", credentials: "include", headers: { "X-CSRFToken": pidGetCookie("csrftoken") || "" }, }); if (!response.ok) { let mensagem = "Não foi possível gerar o arquivo."; try { mensagem = pidErrorMessageFrom(await response.json()); } catch (e) { // resposta sem corpo JSON — mantém a mensagem genérica } throw new Error(mensagem); } const blob = await response.blob(); const disposition = response.headers.get("Content-Disposition") || ""; const match = disposition.match(/filename="?([^"]+)"?/); const filename = match ? match[1] : "importacao_plano_saude.csv"; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } document.addEventListener("DOMContentLoaded", async () => { const listView = document.getElementById("ips-list-view"); if (!listView) return; const me = await pidGetMe(); if (!me) return; const utilPerms = (me.permissoes_efetivas && me.permissoes_efetivas["utilitarios"]) || {}; const canUse = !!(utilPerms.apps && utilPerms.apps["importacao-plano-saude"]); const noAccessEl = document.getElementById("ips-no-access"); const contentEl = document.getElementById("ips-content"); if (noAccessEl) noAccessEl.hidden = canUse; if (contentEl) contentEl.hidden = !canUse; if (!canUse) return; pidCriarBotaoAjuda("ips-ajuda-btn", "importacao-plano-saude", "Importação de Plano de Saúde"); const formView = document.getElementById("ips-form-view"); const reviewView = document.getElementById("ips-review-view"); const listBody = document.getElementById("ips-list-body"); const listEmpty = document.getElementById("ips-list-empty"); const listPagination = document.getElementById("ips-list-pagination"); const listCount = document.getElementById("ips-list-count"); const listSelectAll = document.getElementById("ips-list-select-all"); const listBulkActions = document.getElementById("ips-list-bulk-actions"); const listBulkCount = document.getElementById("ips-list-bulk-count"); const listBulkDeleteBtn = document.getElementById("ips-list-bulk-delete-btn"); const listSortHeaders = Array.from(document.querySelectorAll("#ips-list-table th[data-sort]")); const newBtn = document.getElementById("ips-new-btn"); const formOperadora = document.getElementById("ips-form-operadora"); const formPlanilha = document.getElementById("ips-form-planilha"); const formPlanilhaName = document.getElementById("ips-form-planilha-name"); const formPlanilhaRemoveBtn = document.getElementById("ips-form-planilha-remove"); const formPlanilhaStatus = document.getElementById("ips-form-planilha-status"); // Origem da planilha padrão: buscar do Questor (padrão) ou anexar // manualmente (alternativa — Questor fora do ar, ou empresa ainda não // migrada). Ver ImportacaoPlanoSaudeCreateSerializer.validate() no // backend, que exige exatamente uma das duas. const planilhaOrigemRadios = Array.from(document.querySelectorAll('input[name="ips-planilha-origem"]')); const planilhaQuestorField = document.getElementById("ips-planilha-questor-field"); const planilhaUploadField = document.getElementById("ips-planilha-upload-field"); const formCompetencia = document.getElementById("ips-form-competencia"); const formArquivo = document.getElementById("ips-form-arquivo"); const formArquivoList = document.getElementById("ips-form-arquivo-list"); const formTipoMensalidade = document.getElementById("ips-form-tipo-mensalidade"); const formTipoCoparticipacao = document.getElementById("ips-form-tipo-coparticipacao"); const formTipoRegraEmpresa = document.getElementById("ips-form-tipo-regra-empresa"); const custeioMensalidade = document.getElementById("ips-custeio-mensalidade"); const custeioCoparticipacao = document.getElementById("ips-custeio-coparticipacao"); const regraEmpresaBox = document.getElementById("ips-regra-empresa-box"); const regraEmpresaAtual = document.getElementById("ips-regra-empresa-atual"); const regraEmpresaSelecionarBtn = document.getElementById("ips-regra-empresa-selecionar-btn"); const regraEmpresaModal = document.getElementById("ips-regra-empresa-modal"); const regraEmpresaLista = document.getElementById("ips-regra-empresa-lista"); const regraEmpresaError = document.getElementById("ips-regra-empresa-error"); const regraEmpresaCancelBtn = document.getElementById("ips-regra-empresa-cancel-btn"); // Tela "Nova Importação" — Empresa/Operadora só resolvem uma regra já // cadastrada (ver "Cadastro de Regras" abaixo), nunca a editam. const impEmpresaSearch = document.getElementById("ips-imp-empresa-search"); const impEmpresaList = document.getElementById("ips-imp-empresa-combo-list"); const impEmpresaValor = document.getElementById("ips-imp-empresa-valor"); const impEmpresaLimparBtn = document.getElementById("ips-imp-empresa-limpar-btn"); const impEmpresaToggleBtn = document.getElementById("ips-imp-empresa-toggle-btn"); const impOperadoraSearch = document.getElementById("ips-imp-operadora-search"); const impOperadoraList = document.getElementById("ips-imp-operadora-combo-list"); const impOperadoraLimparBtn = document.getElementById("ips-imp-operadora-limpar-btn"); const impOperadoraToggleBtn = document.getElementById("ips-imp-operadora-toggle-btn"); const impResumo = document.getElementById("ips-imp-resumo"); const impResumoTipos = document.getElementById("ips-imp-resumo-tipos"); const impResumoMensalidade = document.getElementById("ips-imp-resumo-mensalidade"); const impResumoCoparticipacao = document.getElementById("ips-imp-resumo-coparticipacao"); const impResumoObservacoes = document.getElementById("ips-imp-resumo-observacoes"); // Modal "Cadastro de Regras" — cadastro/edição de RegraCusteioPlanoSaude, // separado da execução da importação (ver acima). const regracadBtn = document.getElementById("ips-regracad-btn"); const regracadModal = document.getElementById("ips-regracad-modal"); const regracadToolbar = document.getElementById("ips-regracad-toolbar"); const regracadEmpresaSearch = document.getElementById("ips-regracad-empresa-search"); const regracadEmpresaList = document.getElementById("ips-regracad-empresa-combo-list"); const regracadEmpresaLimparBtn = document.getElementById("ips-regracad-empresa-limpar-btn"); const regracadEmpresaToggleBtn = document.getElementById("ips-regracad-empresa-toggle-btn"); const regracadOperadoraSearch = document.getElementById("ips-regracad-operadora-search"); const regracadOperadoraList = document.getElementById("ips-regracad-operadora-combo-list"); const regracadOperadoraLimparBtn = document.getElementById("ips-regracad-operadora-limpar-btn"); const regracadOperadoraToggleBtn = document.getElementById("ips-regracad-operadora-toggle-btn"); const regracadNovaBtn = document.getElementById("ips-regracad-nova-btn"); const regracadNovaOperadoraBtn = document.getElementById("ips-regracad-nova-operadora-btn"); const regracadVazio = document.getElementById("ips-regracad-vazio"); const regracadModo = document.getElementById("ips-regracad-modo"); const regracadNovoView = document.getElementById("ips-regracad-novo-view"); const regracadNovoCodigoEmpresa = document.getElementById("ips-regracad-novo-codigo-empresa"); const regracadNovoEmpresaNome = document.getElementById("ips-regracad-novo-empresa-nome"); const regracadNovoOperadoraSearch = document.getElementById("ips-regracad-novo-operadora-search"); const regracadNovoOperadoraList = document.getElementById("ips-regracad-novo-operadora-combo-list"); const regracadNovoOperadoraToggleBtn = document.getElementById("ips-regracad-novo-operadora-toggle-btn"); const regracadNovoOperadoraDuplicada = document.getElementById("ips-regracad-novo-operadora-duplicada"); const regracadFormView = document.getElementById("ips-regracad-form-view"); const regracadObservacoes = document.getElementById("ips-regracad-observacoes"); const regracadError = document.getElementById("ips-regracad-error"); const regracadFecharBtn = document.getElementById("ips-regracad-fechar-btn"); const regracadNovoCancelarBtn = document.getElementById("ips-regracad-novo-cancelar-btn"); const regracadExcluirBtn = document.getElementById("ips-regracad-excluir-btn"); const regracadSalvarBtn = document.getElementById("ips-regracad-salvar-btn"); const regracadCriarBtn = document.getElementById("ips-regracad-criar-btn"); const formError = document.getElementById("ips-form-error"); const formSubmitBtn = document.getElementById("ips-form-submit-btn"); const formCancelBtn = document.getElementById("ips-form-cancel-btn"); const formBackBtn = document.getElementById("ips-form-back-btn"); const reviewEmpresa = document.getElementById("ips-review-empresa"); const reviewSubtitle = document.getElementById("ips-review-subtitle"); const reviewRegraEmpresaObs = document.getElementById("ips-review-regra-empresa-obs"); const reviewRegraEmpresaObsLabel = document.getElementById("ips-review-regra-empresa-obs-label"); const reviewRegraEmpresaObsTexto = document.getElementById("ips-review-regra-empresa-obs-texto"); const reviewError = document.getElementById("ips-review-error"); const reviewBackBtn = document.getElementById("ips-review-back-btn"); const reviewEditarBtn = document.getElementById("ips-review-editar-btn"); const gerarBtn = document.getElementById("ips-gerar-btn"); const tabsEl = document.getElementById("ips-tabs"); const panelsEl = document.getElementById("ips-tab-panels"); const vincularModal = document.getElementById("ips-vincular-modal"); const vincularResumo = document.getElementById("ips-vincular-resumo"); const vincularSearch = document.getElementById("ips-vincular-search"); const vincularLista = document.getElementById("ips-vincular-lista"); const vincularError = document.getElementById("ips-vincular-error"); const vincularCancelBtn = document.getElementById("ips-vincular-cancel-btn"); const vincularConfirmBtn = document.getElementById("ips-vincular-confirm-btn"); let importacoes = []; let importacaoAtual = null; let activeTab = null; let listSortKey = "criado_em"; let listSortDir = -1; let listPage = 1; // Filtros da lista principal, estilo Excel — client-side, sobre o array // já carregado. Cada chave é a mesma de PID_IPS_LISTA_SORT_VALOR (não por // acaso: é o extrator de valor bruto reaproveitado tanto pra ordenar // quanto pra filtrar essa coluna) e vale `null` (sem filtro, mostra tudo) // ou um Set com os valores marcados no popup daquela coluna; combinam // entre si (AND) — ex.: empresa "221" + operadora "3755 - Itamed Saúde" // já restringe pras duas condições juntas. let listFiltros = { codigo_empresa: null, nome_operadora: null, status: null, criado_por: null }; let selectedIds = new Set(); let currentPageIds = []; let colWidthsLinha = {}; let colWidthsAuditoria = {}; // Ordenação por coluna das duas tabelas da revisão (clicar no cabeçalho) — // mesma ideia de listSortKey/listSortDir do histórico, só que aqui é por // painel (linha × auditoria) em vez de por tela inteira; também em // memória, não persistida. As colunas em si têm ordem fixa (PID_IPS_CAMPOS_LINHA/ // PID_IPS_CAMPOS_AUDITORIA) — só as linhas são reordenáveis, por ordenação. let linhaSortKey = null; let linhaSortDir = 1; let auditoriaSortKey = null; let auditoriaSortDir = 1; let vincularItemAtual = null; let vincularLinhaSelecionada = null; let operadorasCache = []; // Regras empresa (portal_api.planos_saude.regras_empresa) — registro fixo // no código; regraEmpresaSelecionada guarda {key, label, tipos_lancamento} // da regra atualmente escolhida no checkbox "Regra especial da empresa" // (ver ips-regra-empresa-modal), dentro do formulário de Cadastro de // Regras. let regrasEmpresaCache = []; let regraEmpresaSelecionada = null; // Banco de regras de custeio cadastradas por empresa+operadora — fonte // única tanto pra tela "Nova Importação" (só resolve/aplica) quanto pra // "Cadastro de Regras" (edita). let regras = []; // Tela "Nova Importação": a regra resolvida a partir da Empresa+Operadora // escolhidas (no máximo uma por combinação, ver unique_together no // backend) — é o que o submit envia, sem nenhum campo editável na tela. let regraResolvidaAtual = null; // Modal "Cadastro de Regras": empresa/operadora escolhidas no modo // "navegar" (edição de uma regra existente) e a regra resolvida por elas; // regracadModoNovo alterna pro formulário de criação (empresa livre + // operadora sem restrição). let regracadEmpresaAtual = null; let regracadOperadoraAtual = null; let regracadRegraEmEdicao = null; 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 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" }); } /** * Valida um único arquivo (planilha padrão OU arquivo da operadora) assim * que o colaborador anexa, chamando /validar-arquivo/ — mesmo parser que * seria usado no "Processar", só que sobre um upload descartável, pra * apontar exatamente qual dos dois documentos está fora do padrão antes de * precisar dos dois juntos. `ehInvalido()` (usado na validação do submit) * só conta como inválido depois que a resposta chega — enquanto pendente * ou sem arquivo, não bloqueia o envio (o create() no servidor valida de * novo, é só essa checagem antecipada que não é garantida). */ function criarValidadorArquivo({ input, nameEl, removeBtn, statusEl, tipo, getOperadoraKey }) { let estadoAtual = null; function limpar() { input.value = ""; nameEl.textContent = "Nenhum arquivo selecionado"; removeBtn.hidden = true; statusEl.hidden = true; statusEl.className = "ips-file-field__status"; statusEl.textContent = ""; estadoAtual = null; } async function validar() { const arquivo = input.files[0]; if (!arquivo) { limpar(); return; } nameEl.textContent = arquivo.name; removeBtn.hidden = false; statusEl.hidden = false; statusEl.className = "ips-file-field__status ips-file-field__status--pendente"; statusEl.textContent = "Validando arquivo..."; estadoAtual = null; try { const operadoraKey = getOperadoraKey ? getOperadoraKey() : null; const resultado = await pidValidarArquivoPlanoSaude(tipo, arquivo, operadoraKey); estadoAtual = resultado; statusEl.className = `ips-file-field__status ${resultado.valido ? "ips-file-field__status--ok" : "ips-file-field__status--erro"}`; statusEl.textContent = `${resultado.valido ? "✓" : "✗"} ${resultado.mensagem}`; } catch (e) { estadoAtual = { valido: false, mensagem: e.message }; statusEl.className = "ips-file-field__status ips-file-field__status--erro"; statusEl.textContent = `✗ ${e.message}`; } } input.addEventListener("change", validar); removeBtn.addEventListener("click", limpar); return { limpar, revalidarSeAnexado: () => { if (input.files[0]) validar(); }, ehInvalido: () => estadoAtual !== null && estadoAtual.valido === false, }; } const validadorPlanilha = criarValidadorArquivo({ input: formPlanilha, nameEl: formPlanilhaName, removeBtn: formPlanilhaRemoveBtn, statusEl: formPlanilhaStatus, tipo: "planilha", }); /* * "2. Arquivo da operadora" aceita 1+ arquivos (algumas operadoras mandam * mensalidade e coparticipação em arquivos separados) — cada arquivo * anexado é validado individualmente (mesmo endpoint de sempre, * pidValidarArquivoPlanoSaude, chamado uma vez por arquivo) e listado com * seu próprio status + botão de remover. Diferente de * criarValidadorArquivo() acima (um único arquivo, pensado pra planilha * padrão), este mantém um array de {file, resultado} e re-renderiza a * lista a cada mudança. */ function criarValidadorArquivoMultiplo({ input, listEl, tipo, getOperadoraKey }) { let arquivos = []; function render() { listEl.innerHTML = ""; arquivos.forEach((item, index) => { const li = document.createElement("li"); li.className = "ips-arquivo-list__item"; const info = document.createElement("div"); info.className = "ips-arquivo-list__info"; const nameEl = document.createElement("span"); nameEl.className = "ips-arquivo-list__name"; nameEl.textContent = item.file.name; const statusEl = document.createElement("span"); if (item.resultado === null) { statusEl.className = "ips-arquivo-list__status ips-arquivo-list__status--pendente"; statusEl.textContent = "Validando arquivo..."; } else { statusEl.className = `ips-arquivo-list__status ${item.resultado.valido ? "ips-arquivo-list__status--ok" : "ips-arquivo-list__status--erro"}`; statusEl.textContent = `${item.resultado.valido ? "✓" : "✗"} ${item.resultado.mensagem}`; } info.appendChild(nameEl); info.appendChild(statusEl); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "icon-btn ips-arquivo-list__remove"; removeBtn.setAttribute("aria-label", "Remover arquivo"); removeBtn.title = "Remover arquivo"; removeBtn.innerHTML = ''; removeBtn.addEventListener("click", () => { arquivos.splice(index, 1); render(); }); li.appendChild(info); li.appendChild(removeBtn); listEl.appendChild(li); }); } async function validarItem(item) { item.resultado = null; render(); try { const operadoraKey = getOperadoraKey ? getOperadoraKey() : null; item.resultado = await pidValidarArquivoPlanoSaude(tipo, item.file, operadoraKey); } catch (e) { item.resultado = { valido: false, mensagem: e.message }; } render(); } function limpar() { input.value = ""; arquivos = []; render(); } input.addEventListener("change", () => { const novosArquivos = Array.from(input.files || []).map((file) => ({ file, resultado: null })); input.value = ""; arquivos = arquivos.concat(novosArquivos); render(); novosArquivos.forEach(validarItem); }); return { limpar, revalidarSeAnexado: () => arquivos.forEach(validarItem), ehVazio: () => arquivos.length === 0, ehInvalido: () => arquivos.some((item) => item.resultado !== null && item.resultado.valido === false), arquivosSelecionados: () => arquivos.map((item) => item.file), }; } const validadorArquivo = criarValidadorArquivoMultiplo({ input: formArquivo, listEl: formArquivoList, tipo: "operadora", getOperadoraKey: () => formOperadora.value, }); function planilhaOrigemAtual() { const marcado = planilhaOrigemRadios.find((r) => r.checked); return marcado ? marcado.value : "questor"; } function atualizarPlanilhaOrigemView() { const questor = planilhaOrigemAtual() === "questor"; planilhaQuestorField.hidden = !questor; planilhaUploadField.hidden = questor; // Trocar de modo limpa o outro — evita enviar um arquivo esquecido // junto com uma competência (ou vice-versa), o que o backend recusaria // mesmo (ImportacaoPlanoSaudeCreateSerializer exige exatamente uma // origem). if (questor) { validadorPlanilha.limpar(); } else { formCompetencia.value = ""; } } planilhaOrigemRadios.forEach((radio) => { radio.addEventListener("change", atualizarPlanilhaOrigemView); }); // Máscara MM/AAAA de digitação — mesmo padrão já usado em // pidIndMascaraCompetencia/pidIndCompetenciaParaIso (indicador-desempenho.js), // copiado aqui em vez de compartilhado (cada arquivo mantém sua própria // cópia dessas funções pequenas, mesmo padrão de comparaValoresUsuario // etc.). Usuário só digita "082026" (ou já com a barra) e o campo forma // "08/2026" sozinho; nunca usar um `` aqui — o // seletor nativo do browser foi considerado horrível pelo usuário. function mascaraCompetencia(valor) { const digitos = (valor || "").replace(/\D/g, "").slice(0, 6); if (digitos.length <= 2) return digitos; return `${digitos.slice(0, 2)}/${digitos.slice(2)}`; } function competenciaParaIso(valor) { const match = /^(\d{2})\/(\d{4})$/.exec(valor || ""); if (!match) return null; const mes = Number(match[1]); if (mes < 1 || mes > 12) return null; return `${match[2]}-${match[1]}-01`; } if (formCompetencia) { formCompetencia.addEventListener("input", (event) => { event.target.value = mascaraCompetencia(event.target.value); }); } function showView(view) { listView.hidden = view !== "list"; formView.hidden = view !== "form"; reviewView.hidden = view !== "review"; } function tiposLabel(tipos) { return (tipos || []).map((t) => PID_IPS_TIPO_LABELS[t] || t).join(" + "); } const IPS_LIST_PAGE_SIZE = 10; // Compara numericamente quando os dois valores parecem número (ex.: // "218" vs "9" — string pura ordenaria "9" depois de "218"), senão cai pra // comparação de texto (acentuada/case-insensitive) — cobre igual bem // "Cód. Empresa" (numérico) e as colunas de texto/data ISO (que já ordenam // certo por comparação de string). function comparaValoresLista(a, b) { const an = Number(a); const bn = Number(b); const ambosNumeros = a !== "" && b !== "" && !Number.isNaN(an) && !Number.isNaN(bn); if (ambosNumeros) return an - bn; return String(a).localeCompare(String(b), "pt-BR", { sensitivity: "base" }); } const PID_IPS_LISTA_SORT_VALOR = { codigo_empresa: (imp) => imp.codigo_empresa || "", nome_operadora: (imp) => imp.nome_operadora || "", tipos: (imp) => tiposLabel(imp.tipos_lancamento), status: (imp) => imp.status || "", criado_por: (imp) => imp.criado_por_nome || "", criado_em: (imp) => imp.criado_em || "", }; // Colunas com filtro "estilo Excel" no cabeçalho do histórico — mesma // chave de PID_IPS_LISTA_SORT_VALOR, reaproveitado como extrator do valor // bruto de cada linha pro popup também (ver criarFiltroColuna). "status" // ganha um rótulo amigável no popup (a lista mostra "Em revisão", não // "revisao"), as outras exibem o próprio valor. const PID_IPS_STATUS_LABELS = { revisao: "Em revisão", concluida: "Concluída" }; function labelValorFiltroLista(campo, valor) { if (campo === "status") return PID_IPS_STATUS_LABELS[valor] || valor; return valor || "(Vazio)"; } function renderListPagination(totalPaginas) { if (totalPaginas <= 1) { listPagination.innerHTML = ""; return; } listPagination.innerHTML = ` Página ${listPage} de ${totalPaginas} `; } // "Selecionar todos" só marca/desmarca os ids da página atualmente // visível (currentPageIds, recalculado a cada renderList) — nunca a // lista inteira nas outras páginas, para respeitar o que está na tela. // A seleção em si (selectedIds) persiste entre trocas de página/ordenação, // então dá pra combinar seleções de páginas diferentes antes de excluir // em lote; ela só é podada quando um id deixa de existir (refreshList). function updateSelectAllState() { if (!listSelectAll) return; const total = currentPageIds.length; const selecionadosNaPagina = currentPageIds.filter((id) => selectedIds.has(id)).length; listSelectAll.disabled = total === 0; listSelectAll.checked = total > 0 && selecionadosNaPagina === total; listSelectAll.indeterminate = selecionadosNaPagina > 0 && selecionadosNaPagina < total; } function updateBulkActionsUI() { const count = selectedIds.size; if (listBulkActions) listBulkActions.hidden = count === 0; if (listBulkCount) listBulkCount.textContent = `${count} selecionada${count === 1 ? "" : "s"}`; } function importacaoPassaNosFiltros(imp) { return Object.entries(listFiltros).every(([campo, valoresPermitidos]) => { if (!valoresPermitidos) return true; // null = sem filtro nessa coluna return valoresPermitidos.has(PID_IPS_LISTA_SORT_VALOR[campo](imp)); }); } function renderList() { const filtradas = importacoes.filter(importacaoPassaNosFiltros); listEmpty.hidden = filtradas.length !== 0; listEmpty.textContent = importacoes.length === 0 ? "Nenhuma importação realizada ainda." : "Nenhuma importação encontrada com esse filtro."; const extrator = PID_IPS_LISTA_SORT_VALOR[listSortKey]; const ordenadas = [...filtradas].sort((a, b) => comparaValoresLista(extrator(a), extrator(b)) * listSortDir); const totalPaginas = Math.max(1, Math.ceil(ordenadas.length / IPS_LIST_PAGE_SIZE)); listPage = Math.min(listPage, totalPaginas); const inicio = (listPage - 1) * IPS_LIST_PAGE_SIZE; const pagina = ordenadas.slice(inicio, inicio + IPS_LIST_PAGE_SIZE); currentPageIds = pagina.map((imp) => imp.id); listBody.innerHTML = ""; pagina.forEach((imp) => { const tr = document.createElement("tr"); tr.dataset.importacaoId = imp.id; tr.style.cursor = "pointer"; const criadoEm = new Date(imp.criado_em).toLocaleString("pt-BR"); const statusClasse = imp.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"; const statusTexto = imp.status === "concluida" ? "Concluída" : "Em revisão"; tr.innerHTML = `
Nenhum resultado encontrado.
`; list.hidden = false; } function render(filtro) { const termo = (filtro || "").trim().toLowerCase(); renderLista(termo ? itens.filter((it) => it.label.toLowerCase().includes(termo)) : itens); } // Botão "x" embutido na própria barra do combobox (dentro de // .ips-combo) — só aparece quando há algum texto (digitado ou // selecionado) pra limpar. function atualizarClearBtn() { if (clearBtn) clearBtn.hidden = !(input && input.value.trim()); } function selecionar(item, { silencioso } = {}) { if (input) input.value = item ? item.label : ""; atualizarClearBtn(); fechar(); if (!silencioso && onChange) onChange(item || null); } if (input) { input.addEventListener("focus", () => render(input.value)); input.addEventListener("input", () => { render(input.value); atualizarClearBtn(); if (onChange) onChange(null); }); } if (list) { list.addEventListener("click", (event) => { const el = event.target.closest("[data-combo-key]"); if (!el) return; const item = itens.find((it) => String(it.key) === el.getAttribute("data-combo-key")); if (item) selecionar(item); }); } if (clearBtn) { // Diferente de limpar() (silencioso, usado só por quem já vai tratar // a cascata na mão) — clicar no "x" precisa disparar onChange(null) // normalmente, pra "limpar Empresa também limpa Operadora" continuar // funcionando (ver regracadEmpresaOps.onChange). clearBtn.addEventListener("click", () => { selecionar(null); if (input) input.focus(); }); atualizarClearBtn(); } if (toggleBtn) { // Botão embutido na barra, ao lado do "x" — pedido explícito do // usuário: mostra a lista completa igual a quando o campo está vazio; // se já houver algo selecionado, mostra as OUTRAS opções cadastradas // (sem repetir a já escolhida), já que só focar o campo com um valor // preenchido filtraria pra mostrar de novo só o item já selecionado. toggleBtn.addEventListener("click", () => { if (input) input.focus(); const atual = input ? input.value.trim() : ""; renderLista(atual ? itens.filter((it) => it.label !== atual) : itens); }); } return { setItens(novos) { itens = novos || []; }, selecionar, limpar() { selecionar(null, { silencioso: true }); }, fechar, contains(el) { return !!comboEl && comboEl.contains(el); }, }; } const impOperadoraOps = criarComboboxTexto({ input: impOperadoraSearch, list: impOperadoraList, clearBtn: impOperadoraLimparBtn, toggleBtn: impOperadoraToggleBtn, onChange: (item) => { formOperadora.value = item ? item.key : ""; const empresaAtual = impEmpresaValor.value; regraResolvidaAtual = item ? regras.find((r) => r.codigo_empresa === empresaAtual && r.operadora === item.key) || null : null; renderResumoRegra(regraResolvidaAtual); if (item) validadorArquivo.revalidarSeAnexado(); }, }); const impEmpresaOps = criarComboboxTexto({ input: impEmpresaSearch, list: impEmpresaList, clearBtn: impEmpresaLimparBtn, toggleBtn: impEmpresaToggleBtn, onChange: (item) => { impEmpresaValor.value = item ? item.key : ""; impOperadoraOps.limpar(); if (item) { impOperadoraOps.setItens(operadorasComRegraParaEmpresa(item.key)); if (impOperadoraSearch) impOperadoraSearch.disabled = false; if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = false; } else { impOperadoraOps.setItens([]); if (impOperadoraSearch) impOperadoraSearch.disabled = true; if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = true; } }, }); // Explica o custeio de uma regra em texto simples pro resumo só-leitura // de "Nova Importação" — nunca editável ali, só exibido. function resumoModoPessoa(entrada) { if (!entrada || !entrada.modo) return "não configurado"; if (entrada.modo === "empresa") return "custeado pela empresa"; if (entrada.modo === "empregado") return "descontado do empregado"; if (entrada.modo === "especifica") { const partes = []; if (entrada.limite_valor !== null && entrada.limite_valor !== undefined) { partes.push(`limite de R$ ${formatarNumeroBr(entrada.limite_valor)}`); } if (entrada.percentual !== null && entrada.percentual !== undefined) { partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`); } if (entrada.limite_desconto_empregado !== null && entrada.limite_desconto_empregado !== undefined) { partes.push(`desconto do empregado limitado a R$ ${formatarNumeroBr(entrada.limite_desconto_empregado)}`); } return `regra específica (${partes.join(", ") || "sem detalhe"})`; } return entrada.modo; } // Tipos de lançamento cobertos pela regra especial de código `chave` // (busca em regrasEmpresaCache, já carregado de GET .../regras-empresa/) // — [] se `chave` for vazia ou não encontrada. function tiposCobertosPorRegraEmpresaChave(chave) { if (!chave) return []; const re = regrasEmpresaCache.find((r) => r.key === chave); return (re && re.tipos_lancamento) || []; } function renderResumoRegra(regra) { if (!impResumo) return; if (!regra) { impResumo.hidden = true; return; } const tipos = regra.tipos_lancamento || []; const custeioPorTipo = regra.custeio_por_tipo || {}; const tiposRegraEmpresa = tiposCobertosPorRegraEmpresaChave(regra.regra_empresa_chave); impResumo.hidden = false; if (impResumoTipos) impResumoTipos.textContent = `Tipos cobertos por esta regra: ${tiposLabel(tipos)}`; if (impResumoMensalidade) { if (tipos.includes("mensalidade")) { if (tiposRegraEmpresa.includes("mensalidade")) { const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave); impResumoMensalidade.textContent = `Mensalidade: usa regra especial da empresa — ${re ? re.label : regra.regra_empresa_chave}`; } else { const titular = resumoModoPessoa(custeioPorTipo.mensalidade && custeioPorTipo.mensalidade.titular); const dependente = resumoModoPessoa(custeioPorTipo.mensalidade && custeioPorTipo.mensalidade.dependente); impResumoMensalidade.textContent = `Mensalidade — Titular: ${titular}. Dependente: ${dependente}.`; } impResumoMensalidade.hidden = false; } else { impResumoMensalidade.hidden = true; } } if (impResumoCoparticipacao) { if (tipos.includes("coparticipacao")) { if (tiposRegraEmpresa.includes("coparticipacao")) { const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave); impResumoCoparticipacao.textContent = `Coparticipação: usa regra especial da empresa — ${re ? re.label : regra.regra_empresa_chave}`; } else { const titular = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.titular); const dependente = resumoModoPessoa(custeioPorTipo.coparticipacao && custeioPorTipo.coparticipacao.dependente); impResumoCoparticipacao.textContent = `Coparticipação — Titular: ${titular}. Dependente: ${dependente}.`; } impResumoCoparticipacao.hidden = false; } else { impResumoCoparticipacao.hidden = true; } } if (impResumoObservacoes) { if (regra.observacoes) { impResumoObservacoes.textContent = `Observações: ${regra.observacoes}`; impResumoObservacoes.hidden = false; } else { impResumoObservacoes.hidden = true; } } } // Monta o FormData de "Nova Importação" a partir da regra resolvida — // mesmo formato de campos que ImportacaoPlanoSaudeCreateSerializer já // espera (custeio_Nenhuma regra empresa cadastrada.
`; return; } regraEmpresaLista.innerHTML = regrasEmpresaCache .map( (r) => `${escapeHtml(r.label)}
, nem forçar reflow
// (offsetWidth, alternar table-layout fixed/auto), nem reconstruir a
// tabela inteira via innerHTML resolvia — uma tabela nova pequena com as
// mesmas classes CSS refluía normalmente, então não era o CSS; era algo
// específico do algoritmo de table-layout do navegador nesta tabela
// grande. CSS Grid usa um algoritmo de dimensionamento completamente
// diferente (grid-template-columns no container, sem nenhuma relação com
// o de
${html} `;
})
.join("");
wireColumnResize(panelsEl);
wireAutoExpandScroll(panelsEl);
}
// ------------------------------------------------------------------
// Vincular pessoa (resolução manual de NOME_DIVERGENTE/NAO_CADASTRADO)
// ------------------------------------------------------------------
function linhaEhTitular(linha) {
return !(linha.nome_dependente || "").trim() && !(linha.cpf_dependente || "").trim();
}
function candidatosVincular(item) {
const ehTitular = item.tipo === "T";
return (importacaoAtual.linhas || []).filter(
(l) => l.tipo_lancamento === item.tipo_lancamento && l.valor === "0" && l.valor_empresa === "0" && linhaEhTitular(l) === ehTitular
);
}
function renderVincularLista(filtro) {
const termo = (filtro || "").trim().toLowerCase();
const candidatos = candidatosVincular(vincularItemAtual).filter((l) => {
const nomePrincipal = (l.nome_dependente || l.nome_func || "").toLowerCase();
const nomeTitular = (l.nome_func || "").toLowerCase();
return nomePrincipal.includes(termo) || nomeTitular.includes(termo);
});
if (!candidatos.length) {
vincularLista.innerHTML = `Nenhuma linha em branco encontrada na planilha padrão. `; return; } vincularLista.innerHTML = candidatos .map((l) => { const nomePrincipal = l.nome_dependente || l.nome_func; const contexto = l.nome_dependente ? `Titular: ${l.nome_func}` : "Titular"; return ` `; }) .join(""); } function abrirVincularModal(item) { vincularItemAtual = item; vincularLinhaSelecionada = null; vincularError.textContent = ""; vincularConfirmBtn.disabled = true; vincularSearch.value = ""; vincularResumo.textContent = `"${item.nome}" (${item.tipo === "T" ? "Titular" : "Dependente"} — ${ PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento }) não foi encontrado automaticamente. Selecione a pessoa correta na planilha padrão:`; renderVincularLista(""); vincularModal.hidden = false; } function fecharVincularModal() { vincularModal.hidden = true; vincularItemAtual = null; vincularLinhaSelecionada = null; } if (vincularModal) { vincularModal.addEventListener("click", (event) => { if (event.target === vincularModal) fecharVincularModal(); }); } if (vincularCancelBtn) vincularCancelBtn.addEventListener("click", fecharVincularModal); if (vincularSearch) { vincularSearch.addEventListener("input", () => renderVincularLista(vincularSearch.value)); } if (vincularLista) { vincularLista.addEventListener("change", (event) => { if (event.target.name !== "ips-vincular-linha") return; vincularLinhaSelecionada = Number(event.target.value); vincularConfirmBtn.disabled = false; }); } if (vincularConfirmBtn) { vincularConfirmBtn.addEventListener("click", async () => { if (!vincularItemAtual || !vincularLinhaSelecionada) return; vincularError.textContent = ""; vincularConfirmBtn.disabled = true; try { await pidResolverAuditoriaPlanoSaude(vincularItemAtual.id, vincularLinhaSelecionada); importacaoAtual = await pidFetchImportacaoPlanoSaude(importacaoAtual.id); fecharVincularModal(); renderTabs(); } catch (e) { vincularError.textContent = e.message; vincularConfirmBtn.disabled = false; } }); } if (newBtn) newBtn.addEventListener("click", openNewForm); if (formCancelBtn) formCancelBtn.addEventListener("click", () => showView("list")); if (formBackBtn) formBackBtn.addEventListener("click", () => showView("list")); if (reviewBackBtn) { reviewBackBtn.addEventListener("click", async () => { showView("list"); await refreshList(); }); } if (reviewEditarBtn) { reviewEditarBtn.addEventListener("click", async () => { reviewEditarBtn.disabled = true; try { importacaoAtual = await pidReabrirImportacaoPlanoSaude(importacaoAtual.id); reviewError.textContent = ""; renderTabs(); } catch (e) { reviewError.textContent = e.message; } finally { reviewEditarBtn.disabled = false; } }); } if (regracadBtn) regracadBtn.addEventListener("click", abrirCadastroRegras); if (regracadFecharBtn) regracadFecharBtn.addEventListener("click", fecharCadastroRegras); if (regracadModal) { // Diferente dos outros modais do app: este formulário costuma ter edição // em andamento (custeio, observações) — clicar fora por engano não deve // descartar isso sem perguntar (pidConfirm, não window.confirm — ver // static/js/confirm-modal.js). regracadModal.addEventListener("click", async (event) => { if (event.target !== regracadModal) return; if (await pidConfirm("Fechar o cadastro de regras sem salvar as alterações?", { perigoso: true })) { fecharCadastroRegras(); } }); } if (regracadNovaBtn) regracadNovaBtn.addEventListener("click", regracadEntrarModoNovo); if (regracadNovaOperadoraBtn) regracadNovaOperadoraBtn.addEventListener("click", regracadEntrarModoNovo); if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.addEventListener("click", regracadCancelarNovo); if (regracadNovoCodigoEmpresa) { regracadNovoCodigoEmpresa.addEventListener("input", agendarAtualizarNomeEmpresaNovo); regracadNovoCodigoEmpresa.addEventListener("blur", atualizarNomeEmpresaNovo); } document.addEventListener("click", (event) => { [impEmpresaOps, impOperadoraOps, regracadEmpresaOps, regracadOperadoraOps, regracadNovoOperadoraOps].forEach( (combo) => { if (combo && !combo.contains(event.target)) combo.fechar(); } ); }); if (formTipoMensalidade) { formTipoMensalidade.addEventListener("change", () => { // Enquanto travado por uma regra especial (ver aplicarTiposRegraEmpresa()), // o checkbox fica disabled e este handler não dispara por clique do // usuário — não precisa de exclusividade mútua explícita aqui. custeioMensalidade.hidden = !formTipoMensalidade.checked; }); } if (formTipoCoparticipacao) { formTipoCoparticipacao.addEventListener("change", () => { custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked; }); } if (formTipoRegraEmpresa) { formTipoRegraEmpresa.addEventListener("change", () => { if (regraEmpresaBox) regraEmpresaBox.hidden = !formTipoRegraEmpresa.checked; if (!formTipoRegraEmpresa.checked) regraEmpresaSelecionada = null; renderRegraEmpresaAtual(); // Enquanto nenhuma regra foi escolhida ainda (checkbox recém-marcado, // regraEmpresaSelecionada ainda null), regraEmpresaTiposAtuais() é [] // e nada é travado — "Mensalidade"/"Coparticipação" continuam livres // até o usuário de fato selecionar uma regra no picker. aplicarTiposRegraEmpresa(); }); } if (regraEmpresaSelecionarBtn) { regraEmpresaSelecionarBtn.addEventListener("click", () => { if (regraEmpresaError) regraEmpresaError.textContent = ""; renderRegraEmpresaLista(); if (regraEmpresaModal) regraEmpresaModal.hidden = false; }); } if (regraEmpresaLista) { regraEmpresaLista.addEventListener("click", (event) => { const btn = event.target.closest("[data-regra-empresa-selecionar]"); if (!btn) return; const key = btn.getAttribute("data-regra-empresa-selecionar"); const regra = regrasEmpresaCache.find((r) => r.key === key); if (!regra) return; regraEmpresaSelecionada = regra; renderRegraEmpresaAtual(); aplicarTiposRegraEmpresa(); if (regraEmpresaModal) regraEmpresaModal.hidden = true; }); } if (regraEmpresaCancelBtn) regraEmpresaCancelBtn.addEventListener("click", () => (regraEmpresaModal.hidden = true)); if (regraEmpresaModal) { regraEmpresaModal.addEventListener("click", (event) => { if (event.target === regraEmpresaModal) regraEmpresaModal.hidden = true; }); } if (regracadSalvarBtn) { regracadSalvarBtn.addEventListener("click", async () => { if (!regracadRegraEmEdicao) return; const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual(); const erro = mensagemErroCusteio(tipos_lancamento); if (erro) { regracadError.textContent = erro; return; } const payload = { codigo_empresa: regracadEmpresaAtual, operadora: regracadOperadoraAtual, regra_empresa_chave: formTipoRegraEmpresa.checked && regraEmpresaSelecionada ? regraEmpresaSelecionada.key : "", tipos_lancamento, custeio_por_tipo, observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "", }; regracadSalvarBtn.disabled = true; try { await pidAtualizarRegraCusteioPlanoSaude(regracadRegraEmEdicao.id, payload); await refreshRegras(); // Pedido explícito do usuário: depois de salvar com sucesso, fecha // o modal em vez de continuar mostrando a regra editada. fecharCadastroRegras(); } catch (e) { regracadError.textContent = e.message; } finally { regracadSalvarBtn.disabled = false; } }); } if (regracadExcluirBtn) { regracadExcluirBtn.addEventListener("click", async () => { if (!regracadRegraEmEdicao) return; if ( !(await pidConfirm(`Excluir a regra "${regracadRegraEmEdicao.nome}"? Essa ação não pode ser desfeita.`, { perigoso: true, })) ) return; regracadExcluirBtn.disabled = true; try { await pidExcluirRegraCusteioPlanoSaude(regracadRegraEmEdicao.id); await refreshRegras(); regracadEmpresaOps.setItens(empresasComRegra()); regracadOperadoraOps.setItens(regracadEmpresaAtual ? operadorasComRegraParaEmpresa(regracadEmpresaAtual) : []); regracadOperadoraOps.limpar(); regracadRegraEmEdicao = null; regracadMostrarVazio(); } catch (e) { regracadError.textContent = e.message; } finally { regracadExcluirBtn.disabled = false; } }); } if (regracadCriarBtn) { regracadCriarBtn.addEventListener("click", async () => { const codigoEmpresa = regracadNovoCodigoEmpresa ? regracadNovoCodigoEmpresa.value.trim() : ""; if (!codigoEmpresa) { regracadError.textContent = "Informe o código da empresa."; return; } if (!regracadNovoOperadoraKey) { regracadError.textContent = "Selecione a operadora."; return; } const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual(); const erro = mensagemErroCusteio(tipos_lancamento); if (erro) { regracadError.textContent = erro; return; } const payload = { codigo_empresa: codigoEmpresa, operadora: regracadNovoOperadoraKey, regra_empresa_chave: formTipoRegraEmpresa.checked && regraEmpresaSelecionada ? regraEmpresaSelecionada.key : "", tipos_lancamento, custeio_por_tipo, observacoes: regracadObservacoes ? regracadObservacoes.value.trim() : "", }; regracadCriarBtn.disabled = true; try { await pidCriarRegraCusteioPlanoSaude(payload); await refreshRegras(); // Pedido explícito do usuário: depois de criar com sucesso, fecha // o modal em vez de continuar mostrando a regra recém-criada. fecharCadastroRegras(); } catch (e) { regracadError.textContent = e.message; } finally { regracadCriarBtn.disabled = false; } }); } PID_IPS_TIPOS.forEach((tipo) => { const custeioBox = document.getElementById(`ips-custeio-${tipo}`); if (!custeioBox) return; custeioBox.addEventListener("change", (event) => { const pessoa = PID_IPS_PESSOAS.find((p) => event.target.name === `ips-custeio-${tipo}-${p}`); if (!pessoa) return; const regraBox = document.getElementById(`ips-regra-especifica-${tipo}-${pessoa}`); if (regraBox) regraBox.hidden = event.target.value !== "especifica"; }); }); // Duplo custeio da "Regra específica" (limite/percentual do lado da // empresa x limite de desconto do lado do empregado) é mutuamente // exclusivo — ver atualizarExclusividadeRegraEspecifica(). Reavalia a cada // tecla digitada nos três campos de cada combinação tipo×pessoa. PID_IPS_TIPOS.forEach((tipo) => { PID_IPS_PESSOAS.forEach((pessoa) => { const chave = `${tipo}-${pessoa}`; [`ips-limite-${chave}`, `ips-percentual-${chave}`, `ips-limite-desconto-${chave}`].forEach((id) => { const input = document.getElementById(id); if (input) input.addEventListener("input", () => atualizarExclusividadeRegraEspecifica(chave)); }); }); }); if (tabsEl) { tabsEl.addEventListener("click", (event) => { const btn = event.target.closest("[data-ips-tab]"); if (!btn) return; activeTab = btn.getAttribute("data-ips-tab"); renderTabs(); }); } if (panelsEl) { panelsEl.addEventListener("change", async (event) => { const input = event.target.closest("[data-linha-campo]"); if (!input) return; const row = input.closest("[data-linha-id]"); const linhaId = Number(row.getAttribute("data-linha-id")); const campo = input.getAttribute("data-linha-campo"); let valorDigitado = input.value.trim(); if (PID_IPS_CAMPOS_EXPRESSAO_VALOR.has(campo)) { const resultado = pidAvaliaExpressaoValorMonetario(valorDigitado); if (resultado !== null) { valorDigitado = resultado; input.value = resultado; // reflete o resultado calculado na própria célula, mesmo antes do refetch abaixo } } try { await pidAtualizarLinhaPlanoSaude(linhaId, { [campo]: valorDigitado }); // Refaz o fetch da importação inteira (não só a linha editada) — // o PATCH da linha não devolve o resumo por tipo nem a lista de // alterações, então sem isso o contador "com valor lançado"/"em // auditoria" e a aba Alterações continuavam mostrando o estado de // antes da edição até o usuário sair e voltar pra essa importação // (bug real reportado pelo usuário — a edição só aparecia depois // de reabrir a tela do zero). importacaoAtual = await pidFetchImportacaoPlanoSaude(importacaoAtual.id); reviewError.textContent = ""; renderTabs(); } catch (e) { reviewError.textContent = e.message; } }); panelsEl.addEventListener("click", async (event) => { if (!event.target.closest(".ips-col-resize-handle")) { const sortTh = event.target.closest("[data-col-campo]"); if (sortTh) { const campo = sortTh.getAttribute("data-col-campo"); if (sortTh.getAttribute("data-col-target") === "auditoria") { if (auditoriaSortKey === campo) auditoriaSortDir *= -1; else { auditoriaSortKey = campo; auditoriaSortDir = 1; } } else { if (linhaSortKey === campo) linhaSortDir *= -1; else { linhaSortKey = campo; linhaSortDir = 1; } } renderPanels(); return; } } const addBtn = event.target.closest("[data-ips-add-linha]"); if (addBtn) { const tipo = addBtn.getAttribute("data-ips-add-linha"); addBtn.disabled = true; try { const novaLinha = await pidCriarLinhaPlanoSaude(importacaoAtual.id, tipo); importacaoAtual = await pidFetchImportacaoPlanoSaude(importacaoAtual.id); reviewError.textContent = ""; renderTabs(); const novaRow = panelsEl.querySelector(`[data-linha-id="${novaLinha.id}"]`); const primeiroInput = novaRow && novaRow.querySelector("[data-linha-campo]"); if (primeiroInput) { primeiroInput.scrollIntoView({ block: "nearest" }); primeiroInput.focus(); } } catch (e) { reviewError.textContent = e.message; addBtn.disabled = false; } return; } const removeBtn = event.target.closest("[data-remover-linha]"); if (removeBtn) { const linhaId = Number(removeBtn.getAttribute("data-remover-linha")); if (!(await pidConfirm("Remover esta linha? Ela não vai aparecer no arquivo gerado.", { perigoso: true }))) return; removeBtn.disabled = true; try { await pidExcluirLinhaPlanoSaude(linhaId); importacaoAtual = await pidFetchImportacaoPlanoSaude(importacaoAtual.id); reviewError.textContent = ""; renderTabs(); } catch (e) { reviewError.textContent = e.message; removeBtn.disabled = false; } return; } const vincularBtn = event.target.closest("[data-vincular-auditoria]"); if (vincularBtn) { const itemId = Number(vincularBtn.getAttribute("data-vincular-auditoria")); const item = (importacaoAtual.itens_auditoria || []).find((i) => i.id === itemId); if (item) abrirVincularModal(item); return; } const reverterBtn = event.target.closest("[data-reverter-alteracao]"); if (reverterBtn) { const alteracaoId = Number(reverterBtn.getAttribute("data-reverter-alteracao")); const alteracao = (importacaoAtual.alteracoes || []).find((a) => a.id === alteracaoId); const mensagemConfirmacao = alteracao && alteracao.tipo === "vinculo_automatico" ? "Apagar este vínculo? O valor lançado nesta linha será zerado e esta divergência de nome voltará a cair em auditoria nas próximas importações." : "Reverter esta alteração?"; if (!(await pidConfirm(mensagemConfirmacao))) return; reverterBtn.disabled = true; try { await pidReverterAlteracaoPlanoSaude(alteracaoId); importacaoAtual = await pidFetchImportacaoPlanoSaude(importacaoAtual.id); reviewError.textContent = ""; renderTabs(); } catch (e) { reviewError.textContent = e.message; reverterBtn.disabled = false; } } }); } if (gerarBtn) { gerarBtn.addEventListener("click", async () => { reviewError.textContent = ""; gerarBtn.disabled = true; try { await pidGerarArquivoPlanoSaude(importacaoAtual.id); // Depois de baixar o arquivo, volta pro histórico — mesmo pedido do // usuário pro fluxo de "Nova Importação" (abrirRevisao já leva pra // lá), já que gerar() marca a importação como concluída e não há // mais nada a revisar imediatamente depois de baixar o arquivo. showView("list"); await refreshList(); } catch (e) { reviewError.textContent = e.message; } finally { gerarBtn.disabled = false; } }); } if (listBody) { listBody.addEventListener("click", async (event) => { const removeBtn = event.target.closest("[data-remover-importacao]"); if (removeBtn) { const id = Number(removeBtn.getAttribute("data-remover-importacao")); if (!(await pidConfirm("Excluir esta importação do histórico? Essa ação não pode ser desfeita.", { perigoso: true }))) return; try { await pidExcluirImportacaoPlanoSaude(id); selectedIds.delete(id); await refreshList(); } catch (e) { await pidAlert(e.message); } return; } if (event.target.closest("[data-select-id]")) return; const row = event.target.closest("[data-importacao-id]"); if (!row) return; const id = Number(row.getAttribute("data-importacao-id")); try { const detalhe = await pidFetchImportacaoPlanoSaude(id); abrirRevisao(detalhe); } catch (e) { await pidAlert(e.message); } }); listBody.addEventListener("change", (event) => { const checkbox = event.target.closest("[data-select-id]"); if (!checkbox) return; const id = Number(checkbox.getAttribute("data-select-id")); if (checkbox.checked) selectedIds.add(id); else selectedIds.delete(id); updateSelectAllState(); updateBulkActionsUI(); }); } if (listSelectAll) { listSelectAll.addEventListener("change", () => { if (listSelectAll.checked) { currentPageIds.forEach((id) => selectedIds.add(id)); } else { currentPageIds.forEach((id) => selectedIds.delete(id)); } renderList(); }); } if (listBulkDeleteBtn) { listBulkDeleteBtn.addEventListener("click", async () => { const ids = Array.from(selectedIds); if (!ids.length) return; const confirmMsg = ids.length === 1 ? "Excluir esta importação do histórico? Essa ação não pode ser desfeita." : `Excluir estas ${ids.length} importações do histórico? Essa ação não pode ser desfeita.`; if (!(await pidConfirm(confirmMsg, { perigoso: true }))) return; listBulkDeleteBtn.disabled = true; try { await Promise.all(ids.map((id) => pidExcluirImportacaoPlanoSaude(id))); selectedIds.clear(); } catch (e) { await pidAlert(e.message); } finally { listBulkDeleteBtn.disabled = false; await refreshList(); } }); } listSortHeaders.forEach((th) => { th.addEventListener("click", (event) => { // Ignora cliques que vieram de dentro do popup de filtro (ver // criarFiltroColuna abaixo) — sem isso, abrir o filtro ou marcar um // checkbox dele também reordenava a coluna, já que o filtro mora // dentro do mesmo clicável.
if (event.target.closest(".ips-col-filter")) return;
const key = th.getAttribute("data-sort");
if (listSortKey === key) {
listSortDir *= -1;
} else {
listSortKey = key;
listSortDir = 1;
}
listPage = 1;
renderList();
});
});
// Filtro "estilo Excel" por coluna do histórico — botão de funil dentro
// do próprio | de ordenação, popup com busca + checklist de valores
// distintos (reaproveita .checklist-* de components.css). `filtrosColuna`
// guarda um `fechar()` por coluna, pra fechar todos ao abrir outro ou ao
// clicar fora de qualquer um deles.
const filtrosColuna = [];
function fecharTodosOsFiltrosColuna() {
filtrosColuna.forEach((f) => f.fechar());
}
function criarFiltroColuna(campo) {
const th = listSortHeaders.find((el) => el.getAttribute("data-sort") === campo);
if (!th) return;
const wrapper = document.createElement("span");
wrapper.className = "ips-col-filter";
wrapper.innerHTML = `
| Nenhum valor encontrado. `; selectAllInput.checked = visiveis.length > 0 && visiveis.every((v) => marcados.has(v)); } function abrir() { // `marcados` sempre recomeça a partir do filtro já aplicado (ou de // todos os valores existentes, se a coluna ainda não tem filtro) — // nunca do que ficou de uma edição anterior cancelada sem "Aplicar". marcados = listFiltros[campo] ? new Set(listFiltros[campo]) : new Set(valoresDistintos()); searchInput.value = ""; renderItens(); popup.hidden = false; btn.setAttribute("aria-expanded", "true"); } function fechar() { popup.hidden = true; marcados = null; btn.setAttribute("aria-expanded", "false"); } function atualizarBotaoAtivo() { btn.classList.toggle("is-active", !!listFiltros[campo]); } btn.addEventListener("click", (event) => { event.stopPropagation(); const estavaAberto = !popup.hidden; fecharTodosOsFiltrosColuna(); if (!estavaAberto) abrir(); }); popup.addEventListener("click", (event) => event.stopPropagation()); searchInput.addEventListener("input", renderItens); // Enter no campo de busca restringe a seleção aos valores que batem com o // termo digitado (igual marcar só eles manualmente) e já aplica — sem // isso, digitar um código e teclar Enter só clicava em "Aplicar" com a // seleção que já estava marcada (o popup abre com "Selecionar todos" // ligado por padrão), então não filtrava nada na prática; era preciso // desmarcar/marcar os checkboxes na mão antes de aplicar. searchInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); const visiveis = valoresVisiveis(); if (visiveis.length) marcados = new Set(visiveis); aplicarBtn.click(); }); selectAllInput.addEventListener("change", () => { const visiveis = valoresVisiveis(); if (selectAllInput.checked) visiveis.forEach((v) => marcados.add(v)); else visiveis.forEach((v) => marcados.delete(v)); renderItens(); }); box.addEventListener("change", (event) => { const input = event.target.closest("[data-valor-filtro]"); if (!input) return; const valor = input.getAttribute("data-valor-filtro"); if (input.checked) marcados.add(valor); else marcados.delete(valor); renderItens(); }); limparBtn.addEventListener("click", () => { listFiltros[campo] = null; fechar(); atualizarBotaoAtivo(); listPage = 1; renderList(); }); aplicarBtn.addEventListener("click", () => { const todos = valoresDistintos(); // Marcar tudo equivale a "sem filtro" — mantém null nesse caso, senão // um valor novo que apareça depois (operadora nova, por exemplo) // ficaria de fora até o usuário marcá-lo manualmente de novo. const marcouTudo = todos.length === marcados.size && todos.every((v) => marcados.has(v)); listFiltros[campo] = marcouTudo ? null : new Set(marcados); fechar(); atualizarBotaoAtivo(); listPage = 1; renderList(); }); filtrosColuna.push({ fechar, atualizarBotaoAtivo }); } ["codigo_empresa", "nome_operadora", "status", "criado_por"].forEach(criarFiltroColuna); const listResetBtn = document.getElementById("ips-list-reset-btn"); if (listResetBtn) { listResetBtn.addEventListener("click", () => { fecharTodosOsFiltrosColuna(); listSortKey = "criado_em"; listSortDir = -1; listFiltros = { codigo_empresa: null, nome_operadora: null, status: null, criado_por: null }; filtrosColuna.forEach((f) => f.atualizarBotaoAtivo()); listPage = 1; renderList(); }); } document.addEventListener("click", (event) => { if (!event.target.closest(".ips-col-filter")) fecharTodosOsFiltrosColuna(); }); if (listPagination) { listPagination.addEventListener("click", (event) => { if (event.target.closest("#ips-list-prev-btn")) { listPage -= 1; renderList(); } else if (event.target.closest("#ips-list-next-btn")) { listPage += 1; renderList(); } }); } if (formSubmitBtn) { formSubmitBtn.addEventListener("click", async () => { if (!regraResolvidaAtual) { formError.textContent = "Selecione a empresa e a operadora."; return; } const origemQuestor = planilhaOrigemAtual() === "questor"; let competenciaIso = null; if (origemQuestor) { competenciaIso = competenciaParaIso(formCompetencia.value); if (!competenciaIso) { formError.textContent = "Informe a competência no formato MM/AAAA para buscar a planilha no Questor."; return; } } else { if (!formPlanilha.files[0]) { formError.textContent = "Anexe a planilha padrão do Questor."; return; } if (validadorPlanilha.ehInvalido()) { formError.textContent = "Corrija a planilha anexada (veja a mensagem abaixo do campo) antes de processar."; return; } } if (validadorArquivo.ehVazio()) { formError.textContent = "Anexe ao menos um arquivo da operadora."; return; } if (validadorArquivo.ehInvalido()) { formError.textContent = "Corrija o(s) arquivo(s) da operadora (veja a mensagem abaixo de cada um) antes de processar."; return; } formError.textContent = ""; const formData = new FormData(); if (origemQuestor) { formData.append("competencia", competenciaIso); } else { formData.append("planilha_padrao", formPlanilha.files[0]); } validadorArquivo.arquivosSelecionados().forEach((file) => formData.append("arquivo_operadora", file)); montarFormDataDeRegra(formData, regraResolvidaAtual); formSubmitBtn.disabled = true; formSubmitBtn.textContent = "Processando..."; try { const importacao = await pidCriarImportacaoPlanoSaude(formData); abrirRevisao(importacao); await refreshList(); } catch (e) { formError.textContent = e.message; } finally { formSubmitBtn.disabled = false; formSubmitBtn.textContent = "Processar"; } }); } await refreshList(); }); |
|---|