3000 lines
131 KiB
JavaScript
3000 lines
131 KiB
JavaScript
// Injetado via <script> inline no template, antes deste arquivo, só na
|
||
// página "Importação de Plano de Saúde - De Paula" (ver
|
||
// templates/importacao-plano-saude-de-paula.html) — na página original
|
||
// (importacao-plano-saude.html) window.PID_IPS_CONFIG não existe, então os
|
||
// defaults abaixo mantêm o comportamento de sempre.
|
||
const PID_IPS_CONFIG = Object.assign(
|
||
{
|
||
appKey: "importacao-plano-saude",
|
||
endpointImportacoes: "/importacoes-plano-saude",
|
||
endpointLinhas: "/importacoes-plano-saude-linhas",
|
||
endpointAuditoria: "/importacoes-plano-saude-auditoria",
|
||
endpointAlteracoes: "/importacoes-plano-saude-alteracoes",
|
||
endpointRegrasCusteio: "/regras-custeio-plano-saude",
|
||
tituloAjuda: "Importação de Plano de Saúde",
|
||
},
|
||
window.PID_IPS_CONFIG || {}
|
||
);
|
||
|
||
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(`${PID_IPS_CONFIG.endpointImportacoes}/`)) || [];
|
||
}
|
||
|
||
async function pidFetchImportacaoPlanoSaude(id) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/${id}/`);
|
||
}
|
||
|
||
async function pidFetchOperadorasPlanoSaude() {
|
||
return (await pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/operadoras/`)) || [];
|
||
}
|
||
|
||
async function pidFetchRegrasEmpresaPlanoSaude() {
|
||
return (await pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/regras-empresa/`)) || [];
|
||
}
|
||
|
||
async function pidExcluirImportacaoPlanoSaude(id) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/${id}/`, { method: "DELETE" });
|
||
}
|
||
|
||
async function pidCriarImportacaoPlanoSaude(formData) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/`, { method: "POST", body: formData });
|
||
}
|
||
|
||
async function pidReabrirImportacaoPlanoSaude(id) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointImportacoes}/${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(`${PID_IPS_CONFIG.endpointImportacoes}/validar-arquivo/`, { method: "POST", body: formData });
|
||
}
|
||
|
||
async function pidAtualizarLinhaPlanoSaude(id, campos) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointLinhas}/${id}/`, { method: "PATCH", body: campos });
|
||
}
|
||
|
||
async function pidCriarLinhaPlanoSaude(importacaoId, tipoLancamento) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointLinhas}/`, {
|
||
method: "POST",
|
||
body: { importacao: importacaoId, tipo_lancamento: tipoLancamento },
|
||
});
|
||
}
|
||
|
||
async function pidExcluirLinhaPlanoSaude(id) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointLinhas}/${id}/`, { method: "DELETE" });
|
||
}
|
||
|
||
async function pidResolverAuditoriaPlanoSaude(itemId, linhaId) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointAuditoria}/${itemId}/resolver/`, {
|
||
method: "POST",
|
||
body: { linha_id: linhaId },
|
||
});
|
||
}
|
||
|
||
async function pidReverterAlteracaoPlanoSaude(id) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointAlteracoes}/${id}/reverter/`, { method: "POST" });
|
||
}
|
||
|
||
async function pidFetchRegrasCusteioPlanoSaude() {
|
||
return (await pidApiRequest(`${PID_IPS_CONFIG.endpointRegrasCusteio}/`)) || [];
|
||
}
|
||
|
||
async function pidCriarRegraCusteioPlanoSaude(dados) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointRegrasCusteio}/`, { method: "POST", body: dados });
|
||
}
|
||
|
||
async function pidAtualizarRegraCusteioPlanoSaude(id, dados) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointRegrasCusteio}/${id}/`, { method: "PATCH", body: dados });
|
||
}
|
||
|
||
async function pidExcluirRegraCusteioPlanoSaude(id) {
|
||
return pidApiRequest(`${PID_IPS_CONFIG.endpointRegrasCusteio}/${id}/`, { method: "DELETE" });
|
||
}
|
||
|
||
async function pidBuscarNomeEmpresaPlanoSaude(codigoEmpresa) {
|
||
return pidApiRequest(
|
||
`${PID_IPS_CONFIG.endpointRegrasCusteio}/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${PID_IPS_CONFIG.endpointImportacoes}/${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[PID_IPS_CONFIG.appKey]);
|
||
|
||
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", PID_IPS_CONFIG.appKey, PID_IPS_CONFIG.tituloAjuda);
|
||
|
||
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 =
|
||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>';
|
||
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 `<input type="month">` 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 = `
|
||
<button type="button" class="btn-outline" id="ips-list-prev-btn" ${listPage === 1 ? "disabled" : ""}>Página anterior</button>
|
||
<span class="ips-list-pagination__info">Página ${listPage} de ${totalPaginas}</span>
|
||
<button type="button" class="btn-outline" id="ips-list-next-btn" ${listPage === totalPaginas ? "disabled" : ""}>Próxima página</button>`;
|
||
}
|
||
|
||
// "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 = `
|
||
<td class="ips-select-col"><input type="checkbox" class="ips-select-row" data-select-id="${imp.id}" aria-label="Selecionar esta importação" ${selectedIds.has(imp.id) ? "checked" : ""} /></td>
|
||
<td>${imp.codigo_empresa || "—"}</td>
|
||
<td>${imp.nome_operadora}</td>
|
||
<td>${tiposLabel(imp.tipos_lancamento)}</td>
|
||
<td><span class="status-pill ${statusClasse}">${statusTexto}</span></td>
|
||
<td>${imp.criado_por_nome || "—"}</td>
|
||
<td>${criadoEm}</td>
|
||
<td class="ips-table-actions">
|
||
<button type="button" class="icon-btn ips-remove-linha" data-remover-importacao="${imp.id}" aria-label="Excluir importação" title="Excluir importação">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
|
||
</button>
|
||
</td>`;
|
||
listBody.appendChild(tr);
|
||
});
|
||
|
||
listSortHeaders.forEach((th) => {
|
||
const key = th.getAttribute("data-sort");
|
||
th.classList.toggle("is-sort-asc", key === listSortKey && listSortDir === 1);
|
||
th.classList.toggle("is-sort-desc", key === listSortKey && listSortDir === -1);
|
||
});
|
||
|
||
renderListPagination(totalPaginas);
|
||
updateSelectAllState();
|
||
updateBulkActionsUI();
|
||
|
||
if (listCount) {
|
||
if (ordenadas.length === 0) {
|
||
listCount.textContent = "";
|
||
} else {
|
||
const fim = Math.min(inicio + IPS_LIST_PAGE_SIZE, ordenadas.length);
|
||
listCount.textContent = `Mostrando ${inicio + 1}–${fim} de ${ordenadas.length} registro${ordenadas.length === 1 ? "" : "s"}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshList() {
|
||
try {
|
||
importacoes = await pidFetchImportacoesPlanoSaude();
|
||
} catch (e) {
|
||
importacoes = [];
|
||
}
|
||
const idsValidos = new Set(importacoes.map((imp) => imp.id));
|
||
Array.from(selectedIds).forEach((id) => {
|
||
if (!idsValidos.has(id)) selectedIds.delete(id);
|
||
});
|
||
renderList();
|
||
}
|
||
|
||
// "Regra especial da empresa" ("Regra empresa" — cobre um subconjunto de
|
||
// tipos de lançamento, mensalidade e/ou coparticipação conforme a regra
|
||
// escolhida, ver regras_empresa.py) — zera o checkbox, esconde a caixa e
|
||
// limpa a seleção em memória.
|
||
function renderRegraEmpresaAtual() {
|
||
if (!regraEmpresaAtual) return;
|
||
regraEmpresaAtual.textContent = regraEmpresaSelecionada
|
||
? `Regra selecionada: ${regraEmpresaSelecionada.label}`
|
||
: "Nenhuma regra selecionada.";
|
||
}
|
||
|
||
// Tipos de lançamento efetivamente cobertos agora mesmo pela regra
|
||
// especial selecionada — [] se o checkbox estiver desmarcado ou nenhuma
|
||
// regra tiver sido escolhida ainda (ver GET .../regras-empresa/, que
|
||
// devolve tipos_lancamento por regra).
|
||
function regraEmpresaTiposAtuais() {
|
||
if (!formTipoRegraEmpresa.checked || !regraEmpresaSelecionada) return [];
|
||
return regraEmpresaSelecionada.tipos_lancamento || [];
|
||
}
|
||
|
||
// Trava (marca + desabilita + esconde o custeio) o checkbox de cada tipo
|
||
// coberto pela regra especial atual, e libera de volta (desmarca +
|
||
// reabilita) qualquer tipo que estava travado por uma regra anterior mas
|
||
// deixou de estar coberto (regra trocada, ou "Regra especial" desmarcada)
|
||
// — chamada sempre que a seleção de regra muda. Mutuamente exclusivo por
|
||
// TIPO, não em bloco: uma regra que só cobre mensalidade (ex.: Tecnomyl)
|
||
// deixa "Coparticipação" livre pra configuração manual normalmente.
|
||
function aplicarTiposRegraEmpresa() {
|
||
const tipos = regraEmpresaTiposAtuais();
|
||
[
|
||
{ checkbox: formTipoMensalidade, custeio: custeioMensalidade, tipo: "mensalidade" },
|
||
{ checkbox: formTipoCoparticipacao, custeio: custeioCoparticipacao, tipo: "coparticipacao" },
|
||
].forEach(({ checkbox, custeio, tipo }) => {
|
||
if (!checkbox) return;
|
||
if (tipos.includes(tipo)) {
|
||
checkbox.checked = true;
|
||
checkbox.disabled = true;
|
||
custeio.hidden = true;
|
||
custeio.querySelectorAll('input[type="radio"]').forEach((r) => (r.checked = false));
|
||
} else if (checkbox.disabled) {
|
||
checkbox.checked = false;
|
||
checkbox.disabled = false;
|
||
custeio.hidden = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
function limparRegraEmpresa() {
|
||
if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = false;
|
||
if (regraEmpresaBox) regraEmpresaBox.hidden = true;
|
||
regraEmpresaSelecionada = null;
|
||
renderRegraEmpresaAtual();
|
||
aplicarTiposRegraEmpresa();
|
||
}
|
||
|
||
// Zera a parte de tipo/custeio do formulário de Cadastro de Regras
|
||
// (tipos_lancamento + modo de custeio/limite/percentual de cada
|
||
// combinação tipo×pessoa) — usado ao entrar no modo "+ Nova regra".
|
||
function limparCusteioForm() {
|
||
formTipoMensalidade.checked = false;
|
||
formTipoMensalidade.disabled = false;
|
||
formTipoCoparticipacao.checked = false;
|
||
formTipoCoparticipacao.disabled = false;
|
||
limparRegraEmpresa();
|
||
custeioMensalidade.hidden = true;
|
||
custeioCoparticipacao.hidden = true;
|
||
custeioMensalidade.querySelectorAll('input[type="radio"]').forEach((r) => (r.checked = false));
|
||
custeioCoparticipacao.querySelectorAll('input[type="radio"]').forEach((r) => (r.checked = false));
|
||
PID_IPS_TIPOS.forEach((tipo) => {
|
||
PID_IPS_PESSOAS.forEach((pessoa) => {
|
||
const chave = `${tipo}-${pessoa}`;
|
||
const regraBox = document.getElementById(`ips-regra-especifica-${chave}`);
|
||
const limiteInput = document.getElementById(`ips-limite-${chave}`);
|
||
const percentualInput = document.getElementById(`ips-percentual-${chave}`);
|
||
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
|
||
if (regraBox) regraBox.hidden = true;
|
||
if (limiteInput) limiteInput.value = "";
|
||
if (percentualInput) percentualInput.value = "";
|
||
if (limiteDescontoInput) limiteDescontoInput.value = "";
|
||
atualizarExclusividadeRegraEspecifica(chave);
|
||
});
|
||
});
|
||
}
|
||
|
||
// "Limite de desconto do empregado" é mutuamente exclusivo com "Limite de
|
||
// valor custeado pela empresa"/"% de custeio da empresa" dentro da mesma
|
||
// combinação tipo×pessoa (pedido explícito do usuário) — os dois lados
|
||
// protegem partes opostas do valor (teto de quanto a empresa paga vs. teto
|
||
// de quanto o empregado paga), então misturar os dois não tem um resultado
|
||
// determinístico único (ver `_calcula_valores`/`_monta_regra_custeio` no
|
||
// backend, que valida a mesma exclusividade). Preencher um lado desabilita
|
||
// visualmente o outro, refletindo no formulário a mesma regra que o
|
||
// backend aplica — não limpa o valor do lado desabilitado, só impede
|
||
// digitar mais nele enquanto o outro lado estiver preenchido.
|
||
function atualizarExclusividadeRegraEspecifica(chave) {
|
||
const limiteInput = document.getElementById(`ips-limite-${chave}`);
|
||
const percentualInput = document.getElementById(`ips-percentual-${chave}`);
|
||
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
|
||
if (!limiteInput || !percentualInput || !limiteDescontoInput) return;
|
||
const descontoPreenchido = limiteDescontoInput.value.trim() !== "";
|
||
const empresaPreenchido = limiteInput.value.trim() !== "" || percentualInput.value.trim() !== "";
|
||
limiteInput.disabled = descontoPreenchido;
|
||
percentualInput.disabled = descontoPreenchido;
|
||
limiteDescontoInput.disabled = empresaPreenchido;
|
||
}
|
||
|
||
// "150,00" / 150 / 150.5 -> "150,00"/"150,50" — usado pra repopular os
|
||
// inputs de limite/percentual (texto BR) a partir de uma regra salva, cujo
|
||
// custeio_por_tipo já vem validado/parseado como float pelo backend.
|
||
function formatarNumeroBr(valor) {
|
||
if (valor === null || valor === undefined || valor === "") return "";
|
||
const numero = Number(valor);
|
||
return Number.isNaN(numero) ? String(valor) : numero.toFixed(2).replace(".", ",");
|
||
}
|
||
|
||
/**
|
||
* Lê o custeio atualmente preenchido no formulário (quais tipos de
|
||
* lançamento estão marcados + o modo de custeio/limite/percentual de cada
|
||
* combinação tipo×pessoa) — mesmo formato de tipos_lancamento/custeio_por_tipo
|
||
* de RegraCusteioPlanoSaude, usado tanto pra montar o payload de "Salvar
|
||
* regra atual..." quanto (via os mesmos campos) pro submit da importação.
|
||
*/
|
||
function coletarCusteioAtual() {
|
||
const tiposRegraEmpresa = regraEmpresaTiposAtuais();
|
||
const tipos_lancamento = [];
|
||
if (formTipoMensalidade.checked || tiposRegraEmpresa.includes("mensalidade")) tipos_lancamento.push("mensalidade");
|
||
if (formTipoCoparticipacao.checked || tiposRegraEmpresa.includes("coparticipacao")) tipos_lancamento.push("coparticipacao");
|
||
|
||
const custeio_por_tipo = {};
|
||
PID_IPS_TIPOS.forEach((tipo) => {
|
||
custeio_por_tipo[tipo] = {};
|
||
// "Regra especial da empresa" substitui o custeio manual dos tipos
|
||
// que ela cobre — não há titular/dependente pra ler aqui pra esses
|
||
// tipos (ver regraEmpresaTiposAtuais()).
|
||
if (tiposRegraEmpresa.includes(tipo)) return;
|
||
PID_IPS_PESSOAS.forEach((pessoa) => {
|
||
const chave = `${tipo}-${pessoa}`;
|
||
const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`);
|
||
if (!escolhido) return;
|
||
const entrada = { modo: escolhido.value };
|
||
if (escolhido.value === "especifica") {
|
||
entrada.limite_valor = document.getElementById(`ips-limite-${chave}`).value.trim();
|
||
entrada.percentual = document.getElementById(`ips-percentual-${chave}`).value.trim();
|
||
entrada.limite_desconto_empregado = document.getElementById(`ips-limite-desconto-${chave}`).value.trim();
|
||
}
|
||
custeio_por_tipo[tipo][pessoa] = entrada;
|
||
});
|
||
});
|
||
|
||
return { tipos_lancamento, custeio_por_tipo };
|
||
}
|
||
|
||
// Valida se o custeio dos tipos marcados está completo (titular e
|
||
// dependente de cada um) — reaproveitado pelo submit da importação e por
|
||
// "Salvar regra atual...", já que as duas ações exigem a mesma coisa:
|
||
// um custeio_por_tipo completo pros tipos selecionados.
|
||
function mensagemErroCusteio(tipos) {
|
||
if (!tipos.length) return "Selecione ao menos um tipo de importação.";
|
||
if (formTipoRegraEmpresa.checked && !regraEmpresaSelecionada) {
|
||
return "Selecione a regra especial a aplicar.";
|
||
}
|
||
const tiposRegraEmpresa = regraEmpresaTiposAtuais();
|
||
for (const tipo of tipos) {
|
||
if (tiposRegraEmpresa.includes(tipo)) continue;
|
||
for (const pessoa of PID_IPS_PESSOAS) {
|
||
const chave = `${tipo}-${pessoa}`;
|
||
const rotulo = `${PID_IPS_TIPO_LABELS[tipo]} (${PID_IPS_PESSOA_LABELS[pessoa]})`;
|
||
const escolhido = document.querySelector(`input[name="ips-custeio-${chave}"]:checked`);
|
||
if (!escolhido) return `Informe o custeio para ${rotulo}.`;
|
||
if (escolhido.value === "especifica") {
|
||
const limite = document.getElementById(`ips-limite-${chave}`).value.trim();
|
||
const percentual = document.getElementById(`ips-percentual-${chave}`).value.trim();
|
||
const limiteDesconto = document.getElementById(`ips-limite-desconto-${chave}`).value.trim();
|
||
if (!limite && !percentual && !limiteDesconto) {
|
||
return `Informe o limite de valor, o percentual de custeio ou o limite de desconto do empregado para ${rotulo}.`;
|
||
}
|
||
if (limiteDesconto && (limite || percentual)) {
|
||
return `${rotulo}: o limite de desconto do empregado não pode ser combinado com o limite de valor/percentual da empresa.`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Repõe no formulário uma regra salva (RegraCusteioPlanoSaude) — os campos
|
||
// continuam 100% editáveis depois, é só um preenchimento em massa, não um
|
||
// modo "somente leitura". `regra.custeio_por_tipo` já vem validado do
|
||
// backend (limite_valor/percentual como float, não texto BR).
|
||
function aplicarCusteio(regra) {
|
||
const tipos = Array.isArray(regra.tipos_lancamento) ? regra.tipos_lancamento : [];
|
||
// Repõe o custeio de uma regra dentro do formulário de Cadastro de
|
||
// Regras — a regra especial (se houver) trava os tipos que ela cobre,
|
||
// os demais tipos selecionados seguem custeio manual (ver
|
||
// aplicarTiposRegraEmpresa()).
|
||
limparRegraEmpresa();
|
||
if (regra.regra_empresa_chave) {
|
||
const re = regrasEmpresaCache.find((r) => r.key === regra.regra_empresa_chave);
|
||
regraEmpresaSelecionada = re || { key: regra.regra_empresa_chave, label: regra.regra_empresa_chave, tipos_lancamento: tipos };
|
||
if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = true;
|
||
if (regraEmpresaBox) regraEmpresaBox.hidden = false;
|
||
renderRegraEmpresaAtual();
|
||
}
|
||
const tiposRegraEmpresa = regraEmpresaTiposAtuais();
|
||
aplicarTiposRegraEmpresa();
|
||
// aplicarTiposRegraEmpresa() já cuidou dos tipos cobertos pela regra
|
||
// especial (checked+disabled+custeio escondido) — só os tipos NÃO
|
||
// cobertos ainda precisam refletir o `tipos_lancamento` salvo aqui.
|
||
if (!tiposRegraEmpresa.includes("mensalidade")) {
|
||
formTipoMensalidade.checked = tipos.includes("mensalidade");
|
||
custeioMensalidade.hidden = !formTipoMensalidade.checked;
|
||
}
|
||
if (!tiposRegraEmpresa.includes("coparticipacao")) {
|
||
formTipoCoparticipacao.checked = tipos.includes("coparticipacao");
|
||
custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked;
|
||
}
|
||
|
||
const custeioPorTipo = regra.custeio_por_tipo || {};
|
||
PID_IPS_TIPOS.forEach((tipo) => {
|
||
PID_IPS_PESSOAS.forEach((pessoa) => {
|
||
const chave = `${tipo}-${pessoa}`;
|
||
const regraBox = document.getElementById(`ips-regra-especifica-${chave}`);
|
||
const limiteInput = document.getElementById(`ips-limite-${chave}`);
|
||
const percentualInput = document.getElementById(`ips-percentual-${chave}`);
|
||
const limiteDescontoInput = document.getElementById(`ips-limite-desconto-${chave}`);
|
||
|
||
document.querySelectorAll(`input[name="ips-custeio-${chave}"]`).forEach((r) => (r.checked = false));
|
||
if (limiteInput) limiteInput.value = "";
|
||
if (percentualInput) percentualInput.value = "";
|
||
if (limiteDescontoInput) limiteDescontoInput.value = "";
|
||
if (regraBox) regraBox.hidden = true;
|
||
|
||
const entrada = custeioPorTipo[tipo] && custeioPorTipo[tipo][pessoa];
|
||
if (!entrada || !entrada.modo) {
|
||
atualizarExclusividadeRegraEspecifica(chave);
|
||
return;
|
||
}
|
||
const radio = document.querySelector(`input[name="ips-custeio-${chave}"][value="${entrada.modo}"]`);
|
||
if (radio) radio.checked = true;
|
||
if (entrada.modo === "especifica") {
|
||
if (limiteInput) limiteInput.value = formatarNumeroBr(entrada.limite_valor);
|
||
if (percentualInput) percentualInput.value = formatarNumeroBr(entrada.percentual);
|
||
if (limiteDescontoInput) limiteDescontoInput.value = formatarNumeroBr(entrada.limite_desconto_empregado);
|
||
if (regraBox) regraBox.hidden = false;
|
||
}
|
||
atualizarExclusividadeRegraEspecifica(chave);
|
||
});
|
||
});
|
||
}
|
||
|
||
function operadoraLabel(key) {
|
||
const operadora = operadorasCache.find((op) => op.key === key);
|
||
return operadora ? operadora.label : key;
|
||
}
|
||
|
||
// Compara códigos de empresa (string) numericamente quando os dois são
|
||
// numéricos — evita "1006" aparecer antes de "129" por comparação de
|
||
// string pura (mesmo espírito de comparaValoresLista, dedicado aos
|
||
// comboboxes de "Empresa" abaixo).
|
||
function compararCodigoEmpresa(a, b) {
|
||
const an = Number(a);
|
||
const bn = Number(b);
|
||
if (!Number.isNaN(an) && !Number.isNaN(bn)) return an - bn;
|
||
return String(a).localeCompare(String(b), "pt-BR");
|
||
}
|
||
|
||
// "<código> - <nome>" quando o nome já foi resolvido do Questor (ver
|
||
// RegraCusteioPlanoSaudeSerializer.get_nome_empresa) — mesmo padrão que o
|
||
// combobox de Operadora já usa pro próprio nome. Sem nome resolvido
|
||
// ainda, mostra só o código (nunca bloqueia navegar/criar regra).
|
||
function labelEmpresa(codigo) {
|
||
const comNome = regras.find((r) => r.codigo_empresa === codigo && r.nome_empresa);
|
||
return comNome ? `${codigo} - ${comNome.nome_empresa}` : codigo;
|
||
}
|
||
|
||
function empresasComRegra() {
|
||
const codigos = [...new Set(regras.map((r) => r.codigo_empresa))].sort(compararCodigoEmpresa);
|
||
return codigos.map((codigo) => ({ key: codigo, label: labelEmpresa(codigo) }));
|
||
}
|
||
|
||
function operadorasComRegraParaEmpresa(codigoEmpresa) {
|
||
return regras
|
||
.filter((r) => r.codigo_empresa === codigoEmpresa)
|
||
.map((r) => ({ key: r.operadora, label: operadoraLabel(r.operadora) }));
|
||
}
|
||
|
||
/**
|
||
* Fábrica genérica de combobox pesquisável (campo de texto dobrando como
|
||
* "display" do valor selecionado + lista flutuante filtrada por texto,
|
||
* mesmas classes .ips-combo*) — reaproveitada pelos comboboxes de
|
||
* Empresa/Operadora de "Nova Importação" (só resolve uma regra já
|
||
* cadastrada) e de "Cadastro de Regras" (navegar entre regras existentes
|
||
* ou escolher a operadora de uma regra nova), no lugar do antigo par de
|
||
* comboboxes hardcoded (Operadora/Regra de custeio salva) que duplicava a
|
||
* mesma lógica de busca+seleção duas vezes.
|
||
*/
|
||
function criarComboboxTexto({ input, list, clearBtn, toggleBtn, onChange }) {
|
||
let itens = [];
|
||
const comboEl = input ? input.closest(".ips-combo") : null;
|
||
|
||
function fechar() {
|
||
if (list) {
|
||
list.hidden = true;
|
||
list.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
function renderLista(lista) {
|
||
if (!list) return;
|
||
list.innerHTML = lista.length
|
||
? lista
|
||
.map((it) => `<div class="ips-combo__item" data-combo-key="${escapeAttr(String(it.key))}">${escapeHtml(it.label)}</div>`)
|
||
.join("")
|
||
: `<p class="ips-combo__empty">Nenhum resultado encontrado.</p>`;
|
||
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_<tipo>_<pessoa>, limite_valor_.../percentual_...), só
|
||
// que lendo do objeto da regra em memória em vez de inputs editáveis na
|
||
// tela (que não existem mais aqui).
|
||
function montarFormDataDeRegra(formData, regra) {
|
||
formData.append("operadora", regra.operadora);
|
||
formData.append("tipos_lancamento", (regra.tipos_lancamento || []).join(","));
|
||
formData.append("regra_custeio_salva", regra.id);
|
||
if (regra.regra_empresa_chave) {
|
||
formData.append("regra_empresa", regra.regra_empresa_chave);
|
||
}
|
||
const tiposRegraEmpresa = tiposCobertosPorRegraEmpresaChave(regra.regra_empresa_chave);
|
||
(regra.tipos_lancamento || []).forEach((tipo) => {
|
||
if (tiposRegraEmpresa.includes(tipo)) return;
|
||
PID_IPS_PESSOAS.forEach((pessoa) => {
|
||
const entrada = (regra.custeio_por_tipo && regra.custeio_por_tipo[tipo] && regra.custeio_por_tipo[tipo][pessoa]) || {};
|
||
formData.append(`custeio_${tipo}_${pessoa}`, entrada.modo || "");
|
||
if (entrada.modo === "especifica") {
|
||
formData.append(`limite_valor_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_valor));
|
||
formData.append(`percentual_${tipo}_${pessoa}`, formatarNumeroBr(entrada.percentual));
|
||
formData.append(`limite_desconto_empregado_${tipo}_${pessoa}`, formatarNumeroBr(entrada.limite_desconto_empregado));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function refreshRegras() {
|
||
try {
|
||
regras = await pidFetchRegrasCusteioPlanoSaude();
|
||
} catch (e) {
|
||
regras = [];
|
||
}
|
||
}
|
||
|
||
// Lista do modal "Selecionar regra empresa" — registro fixo em
|
||
// portal_api.planos_saude.regras_empresa, sem "Excluir"/"Editar" (só o
|
||
// desenvolvedor cadastra uma regra nova, direto no código).
|
||
function renderRegraEmpresaLista() {
|
||
if (!regraEmpresaLista) return;
|
||
if (!regrasEmpresaCache.length) {
|
||
regraEmpresaLista.innerHTML = `<p class="checklist-empty">Nenhuma regra empresa cadastrada.</p>`;
|
||
return;
|
||
}
|
||
regraEmpresaLista.innerHTML = regrasEmpresaCache
|
||
.map(
|
||
(r) => `
|
||
<div class="ips-regra-row">
|
||
<div class="ips-regra-row__info">
|
||
<p class="ips-regra-row__nome">${escapeHtml(r.label)}</p>
|
||
</div>
|
||
<div class="ips-regra-row__actions">
|
||
<button type="button" class="btn-outline" data-regra-empresa-selecionar="${r.key}">Selecionar</button>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
// Modal "Cadastro de Regras" — navegar (empresa+operadora resolvem no
|
||
// máximo uma regra, ver unique_together no backend) ou criar uma nova.
|
||
const regracadOperadoraOps = criarComboboxTexto({
|
||
input: regracadOperadoraSearch,
|
||
list: regracadOperadoraList,
|
||
clearBtn: regracadOperadoraLimparBtn,
|
||
toggleBtn: regracadOperadoraToggleBtn,
|
||
onChange: (item) => {
|
||
regracadOperadoraAtual = item ? item.key : null;
|
||
if (item && regracadEmpresaAtual) {
|
||
const regra = regras.find((r) => r.codigo_empresa === regracadEmpresaAtual && r.operadora === item.key);
|
||
if (regra) {
|
||
regracadCarregarParaEdicao(regra);
|
||
return;
|
||
}
|
||
}
|
||
regracadRegraEmEdicao = null;
|
||
regracadMostrarVazio();
|
||
},
|
||
});
|
||
|
||
const regracadEmpresaOps = criarComboboxTexto({
|
||
input: regracadEmpresaSearch,
|
||
list: regracadEmpresaList,
|
||
clearBtn: regracadEmpresaLimparBtn,
|
||
toggleBtn: regracadEmpresaToggleBtn,
|
||
onChange: (item) => {
|
||
regracadEmpresaAtual = item ? item.key : null;
|
||
regracadOperadoraOps.limpar();
|
||
if (item) {
|
||
regracadOperadoraOps.setItens(operadorasComRegraParaEmpresa(item.key));
|
||
if (regracadOperadoraSearch) regracadOperadoraSearch.disabled = false;
|
||
if (regracadOperadoraToggleBtn) regracadOperadoraToggleBtn.disabled = false;
|
||
if (regracadNovaOperadoraBtn) regracadNovaOperadoraBtn.hidden = false;
|
||
} else {
|
||
regracadOperadoraOps.setItens([]);
|
||
if (regracadOperadoraSearch) regracadOperadoraSearch.disabled = true;
|
||
if (regracadOperadoraToggleBtn) regracadOperadoraToggleBtn.disabled = true;
|
||
if (regracadNovaOperadoraBtn) regracadNovaOperadoraBtn.hidden = true;
|
||
}
|
||
regracadRegraEmEdicao = null;
|
||
regracadMostrarVazio();
|
||
},
|
||
});
|
||
|
||
const regracadNovoOperadoraOps = criarComboboxTexto({
|
||
input: regracadNovoOperadoraSearch,
|
||
list: regracadNovoOperadoraList,
|
||
toggleBtn: regracadNovoOperadoraToggleBtn,
|
||
onChange: (item) => {
|
||
regracadNovoOperadoraKey = item ? item.key : null;
|
||
regracadAtualizarNovoOperadoraDuplicada();
|
||
},
|
||
});
|
||
|
||
// Mensagem "Editando regra existente"/"Cadastrando regra nova" acima do
|
||
// formulário de custeio — pedido explícito do usuário, pra não confundir
|
||
// os dois modos. `regracadVazio` (nenhum dos dois) não mostra nada.
|
||
function regracadSetModo(texto, classeExtra) {
|
||
if (!regracadModo) return;
|
||
if (!texto) {
|
||
regracadModo.hidden = true;
|
||
regracadModo.textContent = "";
|
||
regracadModo.className = "ips-regracad-modo";
|
||
return;
|
||
}
|
||
regracadModo.hidden = false;
|
||
regracadModo.textContent = texto;
|
||
regracadModo.className = `ips-regracad-modo ${classeExtra}`;
|
||
}
|
||
|
||
function regracadMostrarVazio() {
|
||
if (regracadToolbar) regracadToolbar.hidden = false;
|
||
if (regracadVazio) regracadVazio.hidden = false;
|
||
if (regracadNovoView) regracadNovoView.hidden = true;
|
||
if (regracadFormView) regracadFormView.hidden = true;
|
||
if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.hidden = true;
|
||
if (regracadSalvarBtn) regracadSalvarBtn.hidden = true;
|
||
if (regracadExcluirBtn) regracadExcluirBtn.hidden = true;
|
||
if (regracadCriarBtn) regracadCriarBtn.hidden = true;
|
||
if (regracadError) regracadError.textContent = "";
|
||
regracadSetModo(null);
|
||
}
|
||
|
||
function regracadCarregarParaEdicao(regra) {
|
||
regracadModoNovo = false;
|
||
regracadRegraEmEdicao = regra;
|
||
if (regracadToolbar) regracadToolbar.hidden = false;
|
||
if (regracadNovoView) regracadNovoView.hidden = true;
|
||
if (regracadVazio) regracadVazio.hidden = true;
|
||
if (regracadFormView) regracadFormView.hidden = false;
|
||
aplicarCusteio(regra);
|
||
if (regracadObservacoes) regracadObservacoes.value = regra.observacoes || "";
|
||
if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.hidden = true;
|
||
if (regracadSalvarBtn) regracadSalvarBtn.hidden = false;
|
||
if (regracadExcluirBtn) regracadExcluirBtn.hidden = false;
|
||
if (regracadCriarBtn) regracadCriarBtn.hidden = true;
|
||
if (regracadError) regracadError.textContent = "";
|
||
const empresaLabel = regra.nome_empresa ? `${regra.codigo_empresa} - ${regra.nome_empresa}` : regra.codigo_empresa;
|
||
regracadSetModo(`Editando regra existente: ${empresaLabel} · ${operadoraLabel(regra.operadora)}`, "ips-regracad-modo--editando");
|
||
}
|
||
|
||
// Volta pro estado de navegação (empresa/operadora do topo) a partir do
|
||
// que já estava selecionado ali — usado por regracadMostrarVazio() (via
|
||
// onChange dos combos) e por "Cancelar" em "+ Nova regra".
|
||
function regracadMostrarConformeSelecaoAtual() {
|
||
if (regracadEmpresaAtual && regracadOperadoraAtual) {
|
||
const regra = regras.find(
|
||
(r) => r.codigo_empresa === regracadEmpresaAtual && r.operadora === regracadOperadoraAtual
|
||
);
|
||
if (regra) {
|
||
regracadCarregarParaEdicao(regra);
|
||
return;
|
||
}
|
||
}
|
||
regracadMostrarVazio();
|
||
}
|
||
|
||
// Nome da empresa (Questor), ao lado do campo "Código da empresa" em "+
|
||
// Nova regra" — resolvido/cacheado no backend (ver GET /nome-empresa/),
|
||
// não bloqueia criar a regra se não encontrar nada.
|
||
function limparNomeEmpresaNovo() {
|
||
if (!regracadNovoEmpresaNome) return;
|
||
regracadNovoEmpresaNome.hidden = true;
|
||
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome";
|
||
regracadNovoEmpresaNome.textContent = "";
|
||
}
|
||
|
||
async function atualizarNomeEmpresaNovo() {
|
||
if (regracadNomeEmpresaDebounce) {
|
||
clearTimeout(regracadNomeEmpresaDebounce);
|
||
regracadNomeEmpresaDebounce = null;
|
||
}
|
||
if (!regracadNovoCodigoEmpresa || !regracadNovoEmpresaNome) return;
|
||
const codigo = regracadNovoCodigoEmpresa.value.trim();
|
||
if (!codigo) {
|
||
limparNomeEmpresaNovo();
|
||
return;
|
||
}
|
||
regracadNovoEmpresaNome.hidden = false;
|
||
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--buscando";
|
||
regracadNovoEmpresaNome.textContent = "Buscando nome da empresa...";
|
||
let resultado = null;
|
||
try {
|
||
resultado = await pidBuscarNomeEmpresaPlanoSaude(codigo);
|
||
} catch (e) {
|
||
resultado = null;
|
||
}
|
||
// O código pode ter mudado enquanto a busca estava em andamento —
|
||
// nesse caso, quem disparou de novo já está atualizando o texto.
|
||
if (regracadNovoCodigoEmpresa.value.trim() !== codigo) return;
|
||
if (resultado && resultado.nome_empresa) {
|
||
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome";
|
||
regracadNovoEmpresaNome.textContent = resultado.nome_empresa;
|
||
} else {
|
||
regracadNovoEmpresaNome.className = "ips-regracad-novo__empresa-nome ips-regracad-novo__empresa-nome--nao-encontrada";
|
||
regracadNovoEmpresaNome.textContent = "Empresa não encontrada.";
|
||
}
|
||
// O backend só busca/salva o código normalizado (sem zero à esquerda,
|
||
// ex. "092" -> "92") — reflete isso no campo assim que a resposta
|
||
// chega, pra nunca ficar exibindo/enviando um código "não-canônico".
|
||
if (resultado && resultado.codigo_empresa && resultado.codigo_empresa !== codigo) {
|
||
regracadNovoCodigoEmpresa.value = resultado.codigo_empresa;
|
||
}
|
||
regracadAtualizarNovoOperadoraDuplicada();
|
||
}
|
||
|
||
// Busca o nome assim que o colaborador para de digitar (debounce), sem
|
||
// precisar esperar ele clicar em outro campo — pedido explícito do
|
||
// usuário pra ficar "mais instantâneo". `blur` continua funcionando
|
||
// também (chama a mesma função direto, sem esperar o debounce).
|
||
let regracadNomeEmpresaDebounce = null;
|
||
function agendarAtualizarNomeEmpresaNovo() {
|
||
if (regracadNomeEmpresaDebounce) clearTimeout(regracadNomeEmpresaDebounce);
|
||
regracadNomeEmpresaDebounce = setTimeout(atualizarNomeEmpresaNovo, 350);
|
||
}
|
||
|
||
// Confere se a empresa (campo texto)+operadora (combobox) de "+ Nova
|
||
// regra" já têm uma regra cadastrada — avisa embaixo da Operadora sem
|
||
// bloquear "Criar regra" sozinho (o backend também recusa via
|
||
// unique_together, mas avisar antes evita a viagem de ida e volta).
|
||
function regracadAtualizarNovoOperadoraDuplicada() {
|
||
if (!regracadNovoOperadoraDuplicada) return;
|
||
const codigo = regracadNovoCodigoEmpresa ? regracadNovoCodigoEmpresa.value.trim() : "";
|
||
const duplicada =
|
||
!!codigo &&
|
||
!!regracadNovoOperadoraKey &&
|
||
regras.some((r) => r.codigo_empresa === codigo && r.operadora === regracadNovoOperadoraKey);
|
||
regracadNovoOperadoraDuplicada.hidden = !duplicada;
|
||
if (regracadCriarBtn) regracadCriarBtn.disabled = duplicada;
|
||
}
|
||
|
||
function regracadEntrarModoNovo() {
|
||
regracadModoNovo = true;
|
||
regracadRegraEmEdicao = null;
|
||
regracadNovoOperadoraKey = null;
|
||
if (regracadNovoCodigoEmpresa) regracadNovoCodigoEmpresa.value = regracadEmpresaAtual || "";
|
||
regracadNovoOperadoraOps.setItens(operadorasCache);
|
||
regracadNovoOperadoraOps.limpar();
|
||
limparCusteioForm();
|
||
if (regracadObservacoes) regracadObservacoes.value = "";
|
||
if (regracadToolbar) regracadToolbar.hidden = true;
|
||
if (regracadVazio) regracadVazio.hidden = true;
|
||
if (regracadNovoView) regracadNovoView.hidden = false;
|
||
if (regracadFormView) regracadFormView.hidden = false;
|
||
if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.hidden = false;
|
||
if (regracadSalvarBtn) regracadSalvarBtn.hidden = true;
|
||
if (regracadExcluirBtn) regracadExcluirBtn.hidden = true;
|
||
if (regracadCriarBtn) {
|
||
regracadCriarBtn.hidden = false;
|
||
regracadCriarBtn.disabled = false;
|
||
}
|
||
if (regracadError) regracadError.textContent = "";
|
||
regracadSetModo("Cadastrando regra nova", "ips-regracad-modo--novo");
|
||
regracadAtualizarNovoOperadoraDuplicada();
|
||
if (regracadEmpresaAtual) {
|
||
atualizarNomeEmpresaNovo();
|
||
} else {
|
||
limparNomeEmpresaNovo();
|
||
}
|
||
}
|
||
|
||
// "Cancelar" em "+ Nova regra" — volta pro topo (Empresa/Operadora) sem
|
||
// fechar o modal inteiro, diferente de "Fechar".
|
||
function regracadCancelarNovo() {
|
||
regracadModoNovo = false;
|
||
if (regracadNovoView) regracadNovoView.hidden = true;
|
||
if (regracadNovoCancelarBtn) regracadNovoCancelarBtn.hidden = true;
|
||
regracadMostrarConformeSelecaoAtual();
|
||
}
|
||
|
||
async function abrirCadastroRegras() {
|
||
regracadModal.hidden = false;
|
||
regracadEmpresaAtual = null;
|
||
regracadOperadoraAtual = null;
|
||
regracadRegraEmEdicao = null;
|
||
regracadModoNovo = false;
|
||
regracadEmpresaOps.limpar();
|
||
regracadOperadoraOps.setItens([]);
|
||
regracadOperadoraOps.limpar();
|
||
if (regracadOperadoraSearch) regracadOperadoraSearch.disabled = true;
|
||
if (regracadOperadoraToggleBtn) regracadOperadoraToggleBtn.disabled = true;
|
||
if (regracadNovaOperadoraBtn) regracadNovaOperadoraBtn.hidden = true;
|
||
limparNomeEmpresaNovo();
|
||
// Reseta a visualização já aqui, síncrono e antes de qualquer await —
|
||
// sem isso, o modal reabria mostrando por um instante o que estava na
|
||
// tela (ex. "Cadastrando regra nova") de antes de fechar, só voltando
|
||
// ao estado vazio depois que as buscas abaixo terminassem.
|
||
regracadMostrarVazio();
|
||
try {
|
||
operadorasCache = await pidFetchOperadorasPlanoSaude();
|
||
} catch (e) {
|
||
operadorasCache = [];
|
||
}
|
||
try {
|
||
regrasEmpresaCache = await pidFetchRegrasEmpresaPlanoSaude();
|
||
} catch (e) {
|
||
regrasEmpresaCache = [];
|
||
}
|
||
await refreshRegras();
|
||
regracadEmpresaOps.setItens(empresasComRegra());
|
||
regracadMostrarVazio();
|
||
}
|
||
|
||
function fecharCadastroRegras() {
|
||
regracadModal.hidden = true;
|
||
}
|
||
|
||
async function openNewForm() {
|
||
validadorPlanilha.limpar();
|
||
validadorArquivo.limpar();
|
||
formError.textContent = "";
|
||
formCompetencia.value = "";
|
||
const radioQuestor = planilhaOrigemRadios.find((r) => r.value === "questor");
|
||
if (radioQuestor) radioQuestor.checked = true;
|
||
atualizarPlanilhaOrigemView();
|
||
regraResolvidaAtual = null;
|
||
renderResumoRegra(null);
|
||
impEmpresaOps.limpar();
|
||
impOperadoraOps.setItens([]);
|
||
impOperadoraOps.limpar();
|
||
if (impOperadoraSearch) impOperadoraSearch.disabled = true;
|
||
if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = true;
|
||
try {
|
||
operadorasCache = await pidFetchOperadorasPlanoSaude();
|
||
} catch (e) {
|
||
operadorasCache = [];
|
||
}
|
||
try {
|
||
regrasEmpresaCache = await pidFetchRegrasEmpresaPlanoSaude();
|
||
} catch (e) {
|
||
regrasEmpresaCache = [];
|
||
}
|
||
await refreshRegras();
|
||
impEmpresaOps.setItens(empresasComRegra());
|
||
showView("form");
|
||
}
|
||
|
||
function abrirRevisao(importacao) {
|
||
importacaoAtual = importacao;
|
||
activeTab = null;
|
||
if (reviewEmpresa) {
|
||
if (importacao.codigo_empresa) {
|
||
reviewEmpresa.textContent = importacao.nome_empresa
|
||
? `— ${importacao.codigo_empresa} - ${importacao.nome_empresa}`
|
||
: `— ${importacao.codigo_empresa}`;
|
||
reviewEmpresa.hidden = false;
|
||
} else {
|
||
reviewEmpresa.hidden = true;
|
||
reviewEmpresa.textContent = "";
|
||
}
|
||
}
|
||
const regraEmpresaSufixo = importacao.regra_empresa_label ? ` — Regra empresa: ${importacao.regra_empresa_label}` : "";
|
||
// "competencia" só vem preenchida quando a planilha padrão foi buscada
|
||
// do Questor (não upload manual) — ver ImportacaoPlanoSaude.competencia.
|
||
const competenciaSufixo = importacao.competencia
|
||
? ` — Competência: ${importacao.competencia.slice(5, 7)}/${importacao.competencia.slice(0, 4)}`
|
||
: "";
|
||
reviewSubtitle.textContent =
|
||
`${importacao.nome_operadora} — criada em ${new Date(importacao.criado_em).toLocaleString("pt-BR")}${regraEmpresaSufixo}${competenciaSufixo}`;
|
||
if (reviewRegraEmpresaObs) {
|
||
// As duas regras (empresa e de custeio salva) são mutuamente exclusivas
|
||
// na criação — nunca as duas preenchidas ao mesmo tempo — mas o bloco é
|
||
// o mesmo pra qualquer uma que tenha observação cadastrada.
|
||
if (importacao.regra_empresa_observacoes) {
|
||
reviewRegraEmpresaObsLabel.textContent = "Observações da regra empresa";
|
||
reviewRegraEmpresaObsTexto.textContent = importacao.regra_empresa_observacoes;
|
||
reviewRegraEmpresaObs.hidden = false;
|
||
} else if (importacao.regra_custeio_salva_observacoes) {
|
||
const nome = importacao.regra_custeio_salva_nome ? ` — ${importacao.regra_custeio_salva_nome}` : "";
|
||
reviewRegraEmpresaObsLabel.textContent = `Observações da regra de custeio salva${nome}`;
|
||
reviewRegraEmpresaObsTexto.textContent = importacao.regra_custeio_salva_observacoes;
|
||
reviewRegraEmpresaObs.hidden = false;
|
||
} else {
|
||
reviewRegraEmpresaObs.hidden = true;
|
||
reviewRegraEmpresaObsTexto.textContent = "";
|
||
}
|
||
}
|
||
reviewError.textContent = "";
|
||
renderTabs();
|
||
showView("review");
|
||
}
|
||
|
||
function renderTabs() {
|
||
if (reviewEditarBtn) {
|
||
reviewEditarBtn.hidden = importacaoAtual.status !== "concluida";
|
||
}
|
||
const tipos = importacaoAtual.tipos_lancamento || [];
|
||
const tabKeys = [...tipos, "auditoria", "alteracoes"];
|
||
if (!activeTab || !tabKeys.includes(activeTab)) activeTab = tabKeys[0];
|
||
|
||
tabsEl.innerHTML = tabKeys
|
||
.map((key) => {
|
||
const label = key === "auditoria" ? "Auditoria" : key === "alteracoes" ? "Alterações" : PID_IPS_TIPO_LABELS[key] || key;
|
||
return `<button type="button" class="pa-tab${key === activeTab ? " is-active" : ""}" data-ips-tab="${key}">${label}</button>`;
|
||
})
|
||
.join("");
|
||
|
||
renderPanels();
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return (value === null || value === undefined ? "" : String(value)).replace(/"/g, """);
|
||
}
|
||
|
||
// Igual a escapeAttr, mas também escapa "<"/">"/"&" — usado pra inserir via
|
||
// innerHTML texto livre digitado pelo colaborador (nome/observações de uma
|
||
// regra de custeio salva), que ao contrário do resto dos campos desta tela
|
||
// (nomes de operadora/arquivo, vindos de fontes fixas) pode conter
|
||
// qualquer caractere.
|
||
function escapeHtml(value) {
|
||
return (value === null || value === undefined ? "" : String(value)).replace(
|
||
/[&<>"]/g,
|
||
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]
|
||
);
|
||
}
|
||
|
||
function resizeHandleHtml(campo, target) {
|
||
return `<span class="ips-col-resize-handle" data-resize-campo="${campo}" data-resize-target="${target}"></span>`;
|
||
}
|
||
|
||
// Monta o `grid-template-columns` do .ips-grid a partir dos mesmos pares
|
||
// [campo, label, largura] de sempre — ver a nota grande em wireColumnResize
|
||
// (abaixo) sobre por que essa tabela usa CSS Grid em vez de <table>.
|
||
function gridTemplateColumns(campos, widths, extraColWidth) {
|
||
const extra = extraColWidth ? `${extraColWidth}px ` : "";
|
||
const cols = campos.map(([campo, , largura]) => `${widths[campo] || largura}px`).join(" ");
|
||
return `${extra}${cols}`;
|
||
}
|
||
|
||
// Colunas têm ordem fixa (não são mais arrastáveis) — só o clique pra
|
||
// ordenar as linhas por essa coluna continua disponível. A largura em si
|
||
// não é mais responsabilidade da célula — vem do `grid-template-columns`
|
||
// do container `.ips-grid` (ver gridTemplateColumns() acima).
|
||
function colHeaderHtml(campo, label, target, sortState) {
|
||
const ordenadaPorMim = sortState && sortState.key === campo;
|
||
const sortClasse = ordenadaPorMim ? (sortState.dir === 1 ? " is-sort-asc" : " is-sort-desc") : "";
|
||
return `<div class="ips-grid-cell ips-grid-cell--head ips-col-sortable${sortClasse}" role="columnheader" data-col-campo="${campo}" data-col-target="${target}">${label} <span class="ips-sort-icon">↕</span>${resizeHandleHtml(campo, target)}</div>`;
|
||
}
|
||
|
||
// Tabela de Mensalidade/Coparticipação/Auditoria é montada com CSS Grid
|
||
// (.ips-grid/.ips-grid-row/.ips-grid-cell, importacao-plano-saude.css),
|
||
// não um <table> — trocado depois de confirmar ao vivo, testando com o
|
||
// usuário (inclusive mexendo direto pelo Console, fora do código da
|
||
// aplicação) que ESTE navegador simplesmente não reflui a largura desta
|
||
// tabela em particular (11 colunas, muitas linhas) depois do primeiro
|
||
// layout: nem <col>/<colgroup>, nem `width` no <th>, 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 <table>), então fica imune a esse problema.
|
||
//
|
||
// Pointer Events + setPointerCapture (trava mousemove/mouseup na própria
|
||
// alça, independente de onde o cursor esteja de verdade) + "click" com
|
||
// stopPropagation (a própria célula de cabeçalho é clicável pra ordenar,
|
||
// delegação em panelsEl mais abaixo — sem isso o clique de soltar o
|
||
// arrasto vazava pra cima da célula vizinha e disparava uma ordenação
|
||
// por engano). O arrasto em si só mostra uma linha-guia
|
||
// (.ips-col-resize-guide, position:fixed, puramente visual) seguindo o
|
||
// cursor — a largura só é aplicada de fato ao soltar, via renderPanels()
|
||
// (reconstrói o HTML com o `grid-template-columns` novo).
|
||
function wireColumnResize(root) {
|
||
root.querySelectorAll(".ips-col-resize-handle").forEach((handle) => {
|
||
handle.addEventListener("pointerdown", (event) => {
|
||
if (event.button !== undefined && event.button !== 0) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const headCell = handle.closest(".ips-grid-cell--head");
|
||
const wrap = handle.closest(".ips-table-scroll");
|
||
if (!headCell || !wrap) return;
|
||
const campo = handle.getAttribute("data-resize-campo");
|
||
const widthsState = handle.getAttribute("data-resize-target") === "auditoria" ? colWidthsAuditoria : colWidthsLinha;
|
||
const startX = event.clientX;
|
||
const startWidth = headCell.getBoundingClientRect().width;
|
||
const cellLeft = headCell.getBoundingClientRect().left;
|
||
const wrapRect = wrap.getBoundingClientRect();
|
||
let novaLargura = startWidth;
|
||
|
||
const guide = document.createElement("div");
|
||
guide.className = "ips-col-resize-guide";
|
||
guide.style.top = `${wrapRect.top}px`;
|
||
guide.style.height = `${wrapRect.height}px`;
|
||
document.body.appendChild(guide);
|
||
|
||
function posicionaGuide() {
|
||
guide.style.left = `${cellLeft + novaLargura}px`;
|
||
}
|
||
posicionaGuide();
|
||
document.body.classList.add("is-resizing-column");
|
||
handle.setPointerCapture(event.pointerId);
|
||
|
||
function onPointerMove(e) {
|
||
novaLargura = Math.max(PID_IPS_COL_MIN_WIDTH, Math.round(startWidth + (e.clientX - startX)));
|
||
posicionaGuide();
|
||
}
|
||
function onPointerUp() {
|
||
handle.removeEventListener("pointermove", onPointerMove);
|
||
handle.removeEventListener("pointerup", onPointerUp);
|
||
handle.removeEventListener("pointercancel", onPointerUp);
|
||
document.body.classList.remove("is-resizing-column");
|
||
guide.remove();
|
||
widthsState[campo] = novaLargura;
|
||
renderPanels();
|
||
}
|
||
handle.addEventListener("pointermove", onPointerMove);
|
||
handle.addEventListener("pointerup", onPointerUp);
|
||
handle.addEventListener("pointercancel", onPointerUp);
|
||
});
|
||
handle.addEventListener("click", (event) => event.stopPropagation());
|
||
});
|
||
}
|
||
|
||
// 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
|
||
// (ver linhasIncluidasManualmente() abaixo), nunca numa linha existente
|
||
// por engano.
|
||
const PID_IPS_CAMPOS_SEMPRE_EDITAVEIS = ["valor_empresa", "valor"];
|
||
|
||
// Uma linha é "incluída manualmente" se tiver um ImportacaoPlanoSaudeAlteracao
|
||
// tipo="inclusao" ainda não revertido apontando pra ela — mesmo sinal que já
|
||
// existia pra alimentar a aba "Alterações", não precisou de nenhum campo
|
||
// novo no backend. Só essas linhas ganham os demais campos editáveis;
|
||
// qualquer linha que já veio do processamento (upload ou Questor) só edita
|
||
// Valor Empresa/Valor.
|
||
function linhasIncluidasManualmente() {
|
||
const ids = new Set();
|
||
(importacaoAtual.alteracoes || []).forEach((alt) => {
|
||
if (alt.tipo === "inclusao" && !alt.revertida && alt.linha) ids.add(alt.linha);
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
function panelHtmlParaTipo(tipo) {
|
||
const campos = PID_IPS_CAMPOS_LINHA;
|
||
// Importação concluída: nada é editável até reabrir (botão "Editar" no
|
||
// cabeçalho da Revisão) — nem os valores, nem adicionar/remover linha.
|
||
const concluida = importacaoAtual.status === "concluida";
|
||
const incluidasManualmente = linhasIncluidasManualmente();
|
||
let linhas = (importacaoAtual.linhas || []).filter((l) => l.tipo_lancamento === tipo);
|
||
if (linhaSortKey) {
|
||
linhas = [...linhas].sort((a, b) => comparaValorRevisao(a[linhaSortKey], b[linhaSortKey]) * linhaSortDir);
|
||
}
|
||
const resumo = (importacaoAtual.resumo_por_tipo || []).find((r) => r.tipo_lancamento === tipo);
|
||
const resumoHtml = resumo
|
||
? `<div class="ips-resumo">
|
||
<span><strong>${resumo.total_linhas}</strong> linhas no total</span>
|
||
<span><strong>${resumo.total_lancadas}</strong> com valor lançado</span>
|
||
<span><strong>${resumo.total_auditoria}</strong> em auditoria</span>
|
||
</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);
|
||
const celulasHtml = campos
|
||
.map(([campo]) => {
|
||
const editavel = !concluida && (editavelLivre || PID_IPS_CAMPOS_SEMPRE_EDITAVEIS.includes(campo));
|
||
return editavel
|
||
? `<div class="ips-grid-cell" role="cell"><input type="text" class="ips-table-input" data-linha-campo="${campo}" value="${escapeAttr(linha[campo])}" /></div>`
|
||
: `<div class="ips-grid-cell" role="cell"><span class="ips-table-value">${escapeHtml(linha[campo])}</span></div>`;
|
||
})
|
||
.join("");
|
||
const removerHtml = concluida
|
||
? ""
|
||
: `<button type="button" class="icon-btn ips-remove-linha" data-remover-linha="${linha.id}" aria-label="Remover linha" title="Remover linha">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
|
||
</button>`;
|
||
return `
|
||
<div class="ips-grid-row" role="row" data-linha-id="${linha.id}">
|
||
<div class="ips-grid-cell ips-table-actions" role="cell">${removerHtml}</div>
|
||
${celulasHtml}
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
|
||
return `
|
||
${resumoHtml}
|
||
<div class="pa-table-wrap ips-table-scroll">
|
||
<div class="ips-grid" role="table" style="grid-template-columns:${gridTemplateColumns(campos, colWidthsLinha, 44)}">
|
||
<div class="ips-grid-row" role="row">
|
||
<div class="ips-grid-cell ips-grid-cell--head ips-table-actions" role="columnheader"></div>
|
||
${campos
|
||
.map(([campo, label]) => colHeaderHtml(campo, label, "linha", { key: linhaSortKey, dir: linhaSortDir }))
|
||
.join("")}
|
||
</div>
|
||
${
|
||
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 ? "" : `
|
||
<div class="ips-add-row">
|
||
<button type="button" class="btn-outline" data-ips-add-linha="${tipo}">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>
|
||
Adicionar linha
|
||
</button>
|
||
</div>`}`;
|
||
}
|
||
|
||
// Valor sintético (não vem da API) só pra ordenar a coluna "Ação" —
|
||
// prefixo numérico garante o agrupamento (pendente/resolvido/sem ação)
|
||
// independente da ordenação alfabética do texto exibido em cada estado.
|
||
function auditoriaAcaoOrdem(item) {
|
||
if (item.resolvida) return "1-resolvido";
|
||
if (PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)) return "0-pendente";
|
||
return "2-sem-acao";
|
||
}
|
||
|
||
function panelHtmlAuditoria() {
|
||
const campos = PID_IPS_CAMPOS_AUDITORIA;
|
||
let itens = importacaoAtual.itens_auditoria || [];
|
||
if (!itens.length) {
|
||
return `<p class="ips-empty">Nenhum item em auditoria — tudo foi lançado automaticamente.</p>`;
|
||
}
|
||
if (auditoriaSortKey) {
|
||
const valorOrdenacao =
|
||
auditoriaSortKey === "acao" ? auditoriaAcaoOrdem : (item) => item[auditoriaSortKey];
|
||
itens = [...itens].sort(
|
||
(a, b) => comparaValorRevisao(valorOrdenacao(a), valorOrdenacao(b)) * auditoriaSortDir
|
||
);
|
||
}
|
||
const linhasHtml = itens
|
||
.map((item) => {
|
||
let acaoHtml = `<span class="ips-empty-cell">—</span>`;
|
||
if (item.resolvida) {
|
||
acaoHtml = `<span class="ips-auditoria-resolvido" title="Vinculado a: ${escapeAttr(item.linha_vinculada_nome || "")}">Resolvido${
|
||
item.linha_vinculada_nome ? ` — ${item.linha_vinculada_nome}` : ""
|
||
}</span>`;
|
||
} else if (importacaoAtual.status !== "concluida" && PID_IPS_MOTIVOS_RESOLVIVEIS.includes(item.motivo)) {
|
||
acaoHtml = `<button type="button" class="btn-outline ips-vincular-btn" data-vincular-auditoria="${item.id}">Vincular pessoa</button>`;
|
||
}
|
||
return `
|
||
<div class="ips-grid-row" role="row">
|
||
<div class="ips-grid-cell ips-table-actions" role="cell">${acaoHtml}</div>
|
||
<div class="ips-grid-cell" role="cell">${item.nome || "—"}</div>
|
||
<div class="ips-grid-cell" role="cell">${item.cpf || "—"}</div>
|
||
<div class="ips-grid-cell" role="cell">${item.tipo === "T" ? "Titular" : "Dependente"}</div>
|
||
<div class="ips-grid-cell" role="cell">${PID_IPS_TIPO_LABELS[item.tipo_lancamento] || item.tipo_lancamento || "—"}</div>
|
||
<div class="ips-grid-cell" role="cell">${Number(item.valor).toFixed(2).replace(".", ",")}</div>
|
||
<div class="ips-grid-cell" role="cell"><span class="ips-auditoria-motivo">${PID_IPS_MOTIVO_LABELS[item.motivo] || item.motivo}</span></div>
|
||
<div class="ips-grid-cell" role="cell">${item.detalhe || ""}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
return `
|
||
<div class="pa-table-wrap ips-table-scroll">
|
||
<div class="ips-grid" role="table" style="grid-template-columns:${gridTemplateColumns(campos, colWidthsAuditoria, PID_IPS_AUDITORIA_ACAO_WIDTH)}">
|
||
<div class="ips-grid-row" role="row">
|
||
<div class="ips-grid-cell ips-grid-cell--head ips-table-actions ips-col-sortable${
|
||
auditoriaSortKey === "acao" ? (auditoriaSortDir === 1 ? " is-sort-asc" : " is-sort-desc") : ""
|
||
}" role="columnheader" data-col-campo="acao" data-col-target="auditoria">Ação <span class="ips-sort-icon">↕</span></div>
|
||
${campos
|
||
.map(([campo, label]) => colHeaderHtml(campo, label, "auditoria", { key: auditoriaSortKey, dir: auditoriaSortDir }))
|
||
.join("")}
|
||
</div>
|
||
${linhasHtml}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Aba "Alterações" — histórico de edição/inclusão/exclusão de linha feito
|
||
// manualmente na revisão (ImportacaoPlanoSaudeAlteracao, ver models.py). Já
|
||
// vem do backend ordenado do mais recente pro mais antigo; sem ordenação/
|
||
// redimensionamento de coluna (é um log, não uma planilha editável).
|
||
function panelHtmlAlteracoes() {
|
||
const alteracoes = importacaoAtual.alteracoes || [];
|
||
if (!alteracoes.length) {
|
||
return `<p class="ips-empty">Nenhuma alteração registrada nesta importação ainda.</p>`;
|
||
}
|
||
|
||
const linhasHtml = alteracoes
|
||
.map((alt) => {
|
||
let detalheHtml;
|
||
if (alt.tipo === "edicao") {
|
||
const campoLabel = PID_IPS_CAMPO_LABELS[alt.campo] || alt.campo;
|
||
detalheHtml = `<strong>${escapeHtml(campoLabel)}:</strong> "${escapeHtml(alt.valor_anterior)}" → "${escapeHtml(alt.valor_novo)}"`;
|
||
} else if (alt.tipo === "inclusao") {
|
||
detalheHtml = "Linha incluída manualmente";
|
||
} else if (alt.tipo === "vinculo_automatico") {
|
||
detalheHtml = alt.vinculo_nome_destino
|
||
? `"${escapeHtml(alt.valor_novo)}" (arquivo da operadora) → <strong>${escapeHtml(alt.vinculo_nome_destino)}</strong> (planilha padrão)`
|
||
: `"${escapeHtml(alt.valor_novo)}" (vínculo já apagado)`;
|
||
} else {
|
||
detalheHtml = "Linha removida";
|
||
}
|
||
|
||
const acaoHtml = alt.revertida
|
||
? `<span class="ips-alteracao-revertida">Revertida</span>`
|
||
: importacaoAtual.status === "concluida"
|
||
? `<span class="ips-empty-cell">—</span>`
|
||
: `<button type="button" class="btn-outline ips-reverter-btn" data-reverter-alteracao="${alt.id}">${
|
||
alt.tipo === "vinculo_automatico" ? "Apagar vínculo" : "Reverter"
|
||
}</button>`;
|
||
|
||
return `
|
||
<tr>
|
||
<td class="ips-table-actions">${acaoHtml}</td>
|
||
<td>${new Date(alt.criado_em).toLocaleString("pt-BR")}</td>
|
||
<td><span class="ips-alteracao-tipo ips-alteracao-tipo--${alt.tipo}">${PID_IPS_ALTERACAO_TIPO_LABELS[alt.tipo] || alt.tipo}</span></td>
|
||
<td>${PID_IPS_TIPO_LABELS[alt.tipo_lancamento] || alt.tipo_lancamento || "—"}</td>
|
||
<td>${alt.linha_nome ? escapeHtml(alt.linha_nome) : "—"}</td>
|
||
<td>${detalheHtml}</td>
|
||
<td>${alt.usuario_nome ? escapeHtml(alt.usuario_nome) : "—"}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
|
||
return `
|
||
<div class="pa-table-wrap ips-table-scroll">
|
||
<table class="pa-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="ips-table-actions">Ação</th>
|
||
<th>Quando</th>
|
||
<th>Tipo</th>
|
||
<th>Lançamento</th>
|
||
<th>Linha</th>
|
||
<th>Alteração</th>
|
||
<th>Usuário</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${linhasHtml}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderPanels() {
|
||
const tipos = importacaoAtual.tipos_lancamento || [];
|
||
panelsEl.innerHTML = [...tipos, "auditoria", "alteracoes"]
|
||
.map((key) => {
|
||
const html =
|
||
key === "auditoria" ? panelHtmlAuditoria() : key === "alteracoes" ? panelHtmlAlteracoes() : panelHtmlParaTipo(key);
|
||
return `<div class="pa-tab-panel" data-ips-tab-panel="${key}" ${key === activeTab ? "" : "hidden"}>${html}</div>`;
|
||
})
|
||
.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 = `<p class="checklist-empty">Nenhuma linha em branco encontrada na planilha padrão.</p>`;
|
||
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 `
|
||
<label class="checklist-item">
|
||
<input type="radio" name="ips-vincular-linha" value="${l.id}" ${vincularLinhaSelecionada === l.id ? "checked" : ""} />
|
||
<span class="checklist-item__info">
|
||
<span class="checklist-item__nome">${nomePrincipal}</span>
|
||
<span class="checklist-item__departamento">${contexto}</span>
|
||
</span>
|
||
</label>`;
|
||
})
|
||
.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 <th> 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 <th> 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 = `
|
||
<button type="button" class="ips-col-filter__btn" aria-label="Filtrar coluna" aria-expanded="false" title="Filtrar">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" stroke-linejoin="round"/></svg>
|
||
</button>
|
||
<div class="ips-col-filter__popup" hidden>
|
||
<input type="text" class="checklist-search" placeholder="Buscar..." />
|
||
<label class="modal-checkbox checklist-select-all"><input type="checkbox" checked /> Selecionar todos</label>
|
||
<div class="checklist-box"></div>
|
||
<div class="ips-col-filter__actions">
|
||
<button type="button" class="btn-ghost ips-col-filter__limpar">Limpar</button>
|
||
<button type="button" class="btn-solid ips-col-filter__aplicar">Aplicar</button>
|
||
</div>
|
||
</div>`;
|
||
th.appendChild(wrapper);
|
||
|
||
const btn = wrapper.querySelector(".ips-col-filter__btn");
|
||
const popup = wrapper.querySelector(".ips-col-filter__popup");
|
||
const searchInput = wrapper.querySelector(".checklist-search");
|
||
const selectAllInput = wrapper.querySelector(".checklist-select-all input");
|
||
const box = wrapper.querySelector(".checklist-box");
|
||
const limparBtn = wrapper.querySelector(".ips-col-filter__limpar");
|
||
const aplicarBtn = wrapper.querySelector(".ips-col-filter__aplicar");
|
||
const extrator = PID_IPS_LISTA_SORT_VALOR[campo];
|
||
|
||
let marcados = null; // Set em edição — só existe enquanto o popup está aberto
|
||
|
||
function valoresDistintos() {
|
||
return Array.from(new Set(importacoes.map(extrator))).sort((a, b) =>
|
||
labelValorFiltroLista(campo, a).localeCompare(labelValorFiltroLista(campo, b), "pt-BR", { sensitivity: "base" })
|
||
);
|
||
}
|
||
|
||
function valoresVisiveis() {
|
||
const termo = searchInput.value.trim().toLowerCase();
|
||
return valoresDistintos().filter((v) => labelValorFiltroLista(campo, v).toLowerCase().includes(termo));
|
||
}
|
||
|
||
function renderItens() {
|
||
const visiveis = valoresVisiveis();
|
||
box.innerHTML = visiveis.length
|
||
? visiveis
|
||
.map(
|
||
(v) =>
|
||
`<label class="checklist-item"><input type="checkbox" data-valor-filtro="${escapeAttr(v)}" ${marcados.has(v) ? "checked" : ""} /><span class="checklist-item__info">${escapeHtml(labelValorFiltroLista(campo, v))}</span></label>`
|
||
)
|
||
.join("")
|
||
: `<p class="checklist-empty">Nenhum valor encontrado.</p>`;
|
||
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();
|
||
});
|