diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..96eee43 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(python -c ' *)", + "Bash(.venv/Scripts/python.exe manage.py shell -c ' *)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 7cbee38..0e407f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,7 +148,7 @@ Um único app, `portal_api/`: | `/api/importacoes-plano-saude/{id}/gerar/` | POST | monta o CSV (ou ZIP, se mais de um tipo de lançamento) a partir das linhas já revisadas/editadas e devolve como download binário; marca a importação como `concluida` | | `/api/importacoes-plano-saude-linhas/`, `/api/importacoes-plano-saude-linhas/{id}/` | GET/POST/PATCH/DELETE | edição/inclusão/exclusão de uma linha da revisão (todos os campos, não só valores); mesma permissão da importação, sem conceito de "dono"; as três operações também gravam um `ImportacaoPlanoSaudeAlteracao` (ver "Alterações" abaixo) | | `/api/importacoes-plano-saude-alteracoes/{id}/reverter/` | POST | desfaz uma alteração específica (edição/inclusão/exclusão de linha) registrada na aba "Alterações" da revisão — ver seção própria abaixo | -| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio salvas (`nome`+`operadora`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`, ver "Regras de custeio salvas" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono" | +| `/api/regras-custeio-plano-saude/`, `/api/regras-custeio-plano-saude/{id}/` | GET/POST/PATCH/DELETE | banco de regras de custeio por empresa+operadora (`codigo_empresa`+`operadora`, únicos juntos+`regra_empresa_chave`+`tipos_lancamento`+`custeio_por_tipo`+`observacoes`; `nome` é sempre derivado, nunca aceito do cliente — ver "Cadastro de Regras" abaixo) — mesma permissão de toggle único da ferramenta; lista compartilhada, sem "dono"; cadastro/edição só pela tela "Cadastro de Regras", nunca em "Nova Importação" | | `/api/simulacao-custo-contratacao/gerar/` | POST | calcula (`portal_api.custo_contratacao.calculo.calcula_custo_empregado`) e devolve o PDF direto na resposta (`application/pdf`, sem persistir nada); `PermissaoApp`-like check manual via `permissao_app("geradoc", "simulacao-custo-contratacao")` — ver seção própria abaixo | | `/api/parametros-fiscais-custo-contratacao/` | GET/PATCH | tabelas de INSS/IRRF + parâmetros da Lei 15.270/2025 usados pela simulação (`ParametroFiscalCustoContratacao`, singleton `pk=1`); mesma permissão da simulação, sem par visualizar/editar dedicado | | `/api/indicadores-percentuais-tipo/` | GET/POST/DELETE | histórico de percentuais individual/grupo/departamento por tipo de colaborador (`IndicadorPercentualTipo`) — nunca editado in-place, só criado com `vigente_desde` novo; mesma permissão de toggle único `apps["indicador-desempenho"]` em `permissoes["geradoc"]` | @@ -190,7 +190,7 @@ Um único app, `portal_api/`: | `links-ferramentas.html` | Grade de cartões de atalho para ferramentas externas; reordenar/incluir/remover só com `apps["links-ferramentas-editar"]` (ver seção própria abaixo). | | `acessos-gerais.html` | Cadastro de acessos/logins compartilhados organizados em seções e linhas, com popup de detalhes; criar/editar/excluir seções e linhas só com `apps["acessos-gerais-editar"]` (ver seção "Acessos Gerais" abaixo). | | `ramais.html` | Diretório de ramais internos, filtros por nome/departamento; adicionar/editar ramal, criar ausência e excluir só com `apps.editar` em `ramais` (ver seção "Ramais" abaixo). | -| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + nova importação (upload) + revisão/geração do arquivo de lançamento de plano de saúde (ver seção "Importação de Plano de Saúde" abaixo). | +| `importacao-plano-saude.html` | Ferramenta de Utilitários: histórico + cadastro de regras de custeio por empresa (separado da execução) + nova importação (upload, só aplica uma regra já cadastrada) + revisão/geração do arquivo de lançamento de plano de saúde (ver seção "Importação de Plano de Saúde" abaixo). | | `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver seção "Simulação de Custo de Contratação" abaixo). | | `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver seção própria abaixo). | @@ -535,13 +535,16 @@ portal_api/planos_saude/ ├── itamed/saude.py Itamed Saúde (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome) ├── dental_uni/odonto_mensalidade.py Dental Uni Odonto (PDF via pdfplumber, só mensalidade, casamento por nome) ├── unimed_oeste_pr/saude.py Unimed Oeste do Paraná (PDF via pdfplumber, mensalidade+coparticipação por texto da descrição, casamento por nome) - └── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) + ├── bradesco/saude.py Bradesco Saúde (PDF **sem texto selecionável** — OCR via `docling`, mensalidade+coparticipação, casamento por nome) + └── bradesco/odonto_mensalidade.py Bradesco Dental/Bradessaude Odonto — 3759 (PDF via pdfplumber, mensalidade+coparticipação, casamento por nome, ver nota abaixo) ``` Pra adicionar uma operadora nova: criar `operadoras//.py` implementando `OperadoraParser.extrai()` (devolve `(List[Individuo], List[ItemAuditoria])`) e registrar em `pipeline.OPERADORAS`. **Antes de escrever o parser, ler `projects/importacao-planos-saude.skill`** — documenta decisões de negócio já validadas com o cliente (ex.: nome divergente nunca é resolvido por aproximação, valor final negativo vai pra auditoria, um mesmo beneficiário pode aparecer em várias linhas/rubricas e precisa ser somado) que não devem ser reinterpretadas sem confirmar de novo. **PDF sem texto selecionável (ex.: Bradesco Saúde) precisa de OCR, não de `pdfplumber`**: confirmado rodando `pdfplumber` contra o arquivo real da Bradesco — `page.chars`/`page.extract_text()` vêm vazios em toda página, porque o documento é uma composição de imagens raster (cada linha da tabela é literalmente um bitmap), sem nenhuma camada de texto. Nesse caso o parser usa `docling` (biblioteca de OCR + reconstrução de estrutura de tabela, adicionada ao `requirements.txt` — pesada: traz `torch`/`transformers`/`opencv-python` como dependência transitiva, então o primeiro `pip install` baixa bem mais do que os parsers em `pdfplumber` exigiam) em vez de `pdfplumber`. Ver o docstring de `operadoras/bradesco/saude.py` para o motivo de usar reconstrução de tabela (`DocumentConverter().convert(...).document.tables`, cabeçalho identificado por texto normalizado via `_classifica_coluna`, não por posição fixa) e o contorno de um bug real de fronteira de célula do modelo de tabela (TableFormer) nas colunas numéricas estreitas — valor de uma linha "vazando" pra célula da linha vizinha, contornado extraindo todos os valores monetários da área em ordem de leitura e redistribuindo 1 por linha, em vez de confiar em qual célula específica o modelo atribuiu cada valor. Ao adicionar outra operadora nesse mesmo caso (PDF sem texto selecionável), reaproveitar essa técnica em vez de assumir que `pdfplumber` vai funcionar — testar primeiro com `page.chars`/`extract_text()` contra o arquivo real antes de escolher qual dos dois usar. +**Bradesco Dental / "Bradessaude" Odonto (3759)** — mesmo código de operadora (`CODIGOOUTEMP`) que já existia como "ODONTOPREV S.A." na planilha padrão; o boleto da própria operadora avisa que é o mesmo plano, "antes cobrado como Odontoprev e agora identificado temporariamente como Bradsaude". PDF "SPG/Grupos Especiais - Bradesco Dental - Fatura Técnica" — página 1 é sempre o boleto (sem beneficiário nenhum), a tabela de beneficiários vem a partir da página 2, páginas finais são só o texto legal "MENSAGENS". Titular/dependente vem da coluna "Certif." (`/00` = titular, `/01`, `/02`... = dependente), casamento por nome (sem CPF no arquivo) — mesmo desenho da Bradesco Saúde. Particularidade própria: um mesmo beneficiário pode gerar várias linhas de lançamento por movimentação retroativa (inclusão/cancelamento com efeito em meses anteriores, códigos CM/CR/IR/IM), cada uma com seu próprio Mês/Ano e Valor — todas somadas por indivíduo, igual à regra geral de "somar todas as rubricas do mesmo indivíduo". **Ressalva importante**: ao contrário dos demais parsers deste pacote, este foi escrito só a partir do texto de um PDF colado numa conversa (o arquivo nunca chegou a ficar disponível em disco pra rodar `pdfplumber`/`docling` de verdade) — a extração via `pdfplumber` foi validada batendo a soma dos valores e a contagem de lançamentos contra o resumo do próprio boleto (37 lançamentos, R$ 949,05), mas **ainda precisa ser confirmada rodando o parser contra o arquivo real** (botão "Selecionar arquivo" da tela de Nova Importação já faz isso antes de qualquer coisa ser persistida) — se a extração vier vazia, é sinal de que este PDF também precisa de OCR via `docling`, como a Bradesco Saúde. + **Diferença deliberada em relação ao pipeline original**: lá, o valor do mês sempre gravava na coluna `VALOR` (desconto do empregado), nunca em `VALOREMPRESA` — regra fixa. Aqui, o usuário escolhe na tela de nova importação, **por tipo de lançamento (mensalidade/coparticipação) e por tipo de beneficiário (titular/dependente)** — quatro combinações independentes, ex.: mensalidade do titular custeada pela empresa e mensalidade do dependente descontada do empregado —, uma de três regras de custeio: "Custeado pela empresa" (`{"modo": "empresa"}`), "Descontado do empregado" (`{"modo": "empregado"}`, o comportamento antigo — nomenclatura "empregado", não "funcionário", pra não confundir com `NOMEFUNC`/`CPFFUNC` do leiaute do Questor, que é outra coisa) ou "Regra específica" (`{"modo": "especifica", "limite_valor": float|None, "percentual": float|None}`). Na regra específica, `limite_valor` é um teto de quanto a empresa cobre (o excedente vira desconto do empregado) e `percentual` é a fração do valor do mês custeada pela empresa (o resto vira desconto) — o usuário pode preencher só um dos dois ou os dois juntos; quando os dois vêm preenchidos, prevalece o que resultar no **menor** valor custeado pela empresa (mais restritivo), decisão explícita do usuário. Essa divisão é calculada por `matcher._calcula_valores(valor_total, regra)` (chamada por `_aplica_regra_custeio`, que grava `valor_empresa`/`valor` **os dois juntos** a partir do mesmo `valor_total`) — note que `valor_empresa` é arredondado primeiro e `valor` é derivado como o complemento exato (`valor_total - valor_empresa`, também arredondado), nunca os dois arredondados de forma independente, senão a soma dos dois podia ficar 1 centavo a mais/menos que o valor original (ex.: 50% de 51,69 tem que fechar em 25,84 + 25,85 = 51,69, não 25,85 + 25,85). Qual das duas regras (titular ou dependente) usar em cada `Individuo`/`LinhaSistema` é resolvido por `matcher._regra_para_pessoa(regra_por_pessoa, tipo_pessoa)` — `tipo_pessoa` 'T' cai em "titular", 'D'/'A' caem em "dependente" (mesmo critério de "D e A tratados igual" já usado no resto do leiaute) — chamada nos dois pontos de aplicação de `_casa_por_cpf`/`_casa_por_nome` antes de `_aplica_regra_custeio`. ### Modelos (`portal_api/models.py`) @@ -594,30 +597,47 @@ Antes de existir isso, os dois arquivos (planilha padrão + arquivo da operadora Gerar o arquivo é um download binário (CSV ou ZIP), não JSON — por isso `pidGerarArquivoPlanoSaude()` não usa `pidApiRequest` (que sempre tenta `JSON.parse`); faz um `fetch` manual reaproveitando `pidEnsureCsrfCookie`/`pidGetCookie`/`pidErrorMessageFrom` de `api.js` (funções globais na página) e dispara o download via `URL.createObjectURL`. -### Regras de custeio salvas +### Cadastro de Regras (separado da execução da importação) -Substituiu o antigo par de botões "Exportar regra"/"Importar regra" (baixava/lia um `.json` manualmente, sem nenhuma persistência) por um banco de regras de verdade no Postgres (`RegraCusteioPlanoSaude`) — pedido explícito do usuário pra poder nomear uma regra (ex.: "092 - Unimed"), escolhê-la numa lista em importações futuras, editá-la depois e anotar uma observação livre (ex.: "Empresa não desconta plano do empregado XX"). +Até uma rodada anterior, o custeio (mensalidade/coparticipação por titular/dependente) era configurado **na hora de importar**, em "Nova Importação" — mesmo aplicando uma regra salva, os campos continuavam livres pra edição ali mesmo. O usuário pediu mais segurança operacional: separar de vez o **cadastro** das regras da **execução**, e atrelar cada regra formalmente a uma empresa (antes era só uma convenção de texto livre no campo `nome`, ex. `"092 - Unimed"`, sem nenhum campo estruturado). Duas telas agora: -- **Campos**: `nome` (obrigatório), `operadora` (opcional — a `key` de `planos_saude.pipeline.OPERADORAS`, não o label; só usada pra pré-selecionar o `` `#ips-regra-search` + lista flutuante `#ips-regra-combo-list`, filtra por nome a cada tecla; era um `` simples) pra permitir buscar pelo código/nome da operadora, já que `label` agora vem prefixado com o código de cadastro no Questor (ver `GET /operadoras/` acima). Diferença de implementação: como Operadora não tem um botão "Aplicar" separado (é o próprio campo do formulário, não uma configuração aplicada em massa), o valor de fato submetido viaja num `` — escolher um item da lista (`selecionarOperadora()`) já grava o valor na hora e revalida o arquivo da operadora já anexado (`validadorArquivo.revalidarSeAnexado()`), mesmo efeito que o antigo evento `change` do `` + o texto de busca) é chamada tanto por `resetForm()` quanto por "Limpar seleção" da regra — decisão explícita do usuário: como aplicar uma regra pode ter preenchido a Operadora junto, desfazer a seleção da regra também desfaz o que ela preencheu ali. -- **`ImportacaoPlanoSaude.regra_custeio_salva`** (FK opcional, `SET_NULL`, pra `RegraCusteioPlanoSaude`) registra qual regra (se alguma) estava aplicada no formulário no momento do "Processar" — só informativo, não influencia `custeio_por_tipo` (que já é o que de fato vale pro processamento) nem o processamento em si. Preenchido no submit com `regraAplicadaId` (JS) quando "Regra empresa" **não** está marcada (as duas rastreiam coisas diferentes e nunca são enviadas juntas — marcar "Regra empresa" já limpa `regraAplicadaId` via `limparRegraSelecionada()`, e aplicar uma regra de custeio salva já limpa "Regra empresa" via `limparRegraEmpresa()` dentro de `aplicarCusteio()`). É o que alimenta `regra_custeio_salva_nome`/`regra_custeio_salva_observacoes` na tela de Revisão (ver "Observações da regra, só-leitura na tela de Revisão" acima) — pedido do usuário depois de notar que a observação de uma regra de custeio salva (ex.: "092 - Unimed") não aparecia lá, só a de "Regra empresa". +- **"Cadastro de Regras"** (botão na lista principal, ao lado de "+ Nova Importação", abre `#ips-regracad-modal`) — único lugar onde uma `RegraCusteioPlanoSaude` é criada ou editada. "Empresa" (`#ips-regracad-empresa-combo`, códigos distintos entre as regras já cadastradas, mostrando `" - "` — ver "Nome da empresa (Questor)" abaixo) numa linha própria, com "Operadora" (`#ips-regracad-operadora-combo`, restrito às operadoras com regra pra a empresa escolhida) numa linha abaixo — decisão explícita do usuário, pra o nome da empresa não competir visualmente com a operadora. Os dois comboboxes têm dois botões embutidos na própria barra (ver detalhe em "Nova Importação" abaixo): o "x" pra limpar (`.ips-combo__clear`, só aparece com algo selecionado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível) pra ver de novo a lista completa/as outras opções. Limpar Empresa também limpa Operadora automaticamente (dispara o mesmo `onChange` de quando a empresa é trocada). Como há **no máximo uma regra por combinação empresa+operadora** (`unique_together`, ver abaixo), escolher os dois já resolve a regra pra edição in-place, com "Salvar alterações"/"Excluir regra" — depois de salvar/excluir com sucesso, o modal **fecha** (decisão explícita do usuário; antes continuava mostrando a regra editada). Botão "+ Nova regra" (`#ips-regracad-nova-btn`, ao lado de Empresa) alterna pro modo criação: campo de texto livre "Código da empresa" (`#ips-regracad-novo-codigo-empresa`) com o nome resolvido do Questor ao lado (`#ips-regracad-novo-empresa-nome`, ver "Nome da empresa (Questor)" abaixo) + combobox "Operadora" sem restrição numa linha abaixo (`#ips-regracad-novo-operadora-combo`, catálogo completo de `pipeline.OPERADORAS`) + o mesmo bloco de custeio vazio + "Criar regra" (fecha o modal também, ao concluir). Um segundo botão "+ Nova operadora" (`#ips-regracad-nova-operadora-btn`, ao lado do combobox de Operadora da navegação, só visível quando uma empresa já está selecionada) atalha pro mesmo modo de criação, com o código da empresa já pré-preenchido — pensado pra "essa empresa já tem regra, mas não pra essa operadora". + - **Indicador de modo** (`#ips-regracad-modo`, pedido explícito do usuário pra nunca confundir "editando" com "criando"): mostra "Editando regra existente: `` · ``" (`regracadCarregarParaEdicao()`) ou "Cadastrando regra nova" (`regracadEntrarModoNovo()`) — nada, no estado vazio (`regracadMostrarVazio()`). + - **A barra de navegação (Empresa/Operadora) some no modo "+ Nova regra"** (`#ips-regracad-toolbar`, `hidden` alternado por essas mesmas três funções) — evita mostrar as duas seções (navegação + criação) ao mesmo tempo, o que confundia qual das duas estava "valendo". Um botão **"Cancelar"** (`#ips-regracad-novo-cancelar-btn`, só visível nesse modo) volta pra navegação (`regracadCancelarNovo()` → `regracadMostrarConformeSelecaoAtual()`, que reexibe a regra que estava sendo vista antes, se alguma) sem fechar o modal inteiro — diferente de "Fechar". + - **Aviso de duplicidade em "+ Nova regra"** (`#ips-regracad-novo-operadora-duplicada`, `regracadAtualizarNovoOperadoraDuplicada()`, chamada a cada mudança de código ou de operadora): se a combinação já tiver uma regra cadastrada, mostra "Já existe uma regra cadastrada para esta empresa com esta operadora..." abaixo do combobox de Operadora e desabilita "Criar regra" — evita a viagem de ida e volta até a validação do backend (que também recusa, via `unique_together`) pra descobrir o mesmo problema. + - **Reabrir o modal nunca mostra o estado anterior por um instante**: `abrirCadastroRegras()` chama `regracadMostrarVazio()` de forma síncrona, antes de qualquer `await` (bug real corrigido — antes a limpeza só rodava depois das buscas de operadoras/regras, e o modal reabria mostrando por um instante o que estava na tela antes de ter sido fechado). +- **"Nova Importação"** (formulário de execução) ficou **só leitura** pra custeio: "Empresa" (`#ips-imp-empresa-combo`, mesma fonte do Cadastro, mesmo `" - "`) e "Operadora" (`#ips-imp-operadora-combo`, restrito à empresa escolhida, cada um numa linha própria) resolvem a única regra da combinação (`regraResolvidaAtual`, JS) e mostram um **resumo só-leitura** (`#ips-imp-resumo` — tipos cobertos, custeio de mensalidade/coparticipação, observações), sem nenhum campo editável. Nenhuma empresa aparece nesse combobox sem já ter uma regra cadastrada — cadastrar/editar uma regra pra uma empresa nova é sempre um passo anterior, feito em "Cadastro de Regras". Na tela de Revisão, `#ips-review-empresa` (ao lado do título "Revisão") mostra `" - "` da empresa sendo importada, pra identificar de cara sem precisar abrir a aba de linhas. Os dois comboboxes (aqui e nos três de "Cadastro de Regras") têm dois botões embutidos na própria barra: o "x" (`.ips-combo__clear`, só aparece com algo selecionado/digitado) e uma seta "▾" (`.ips-combo__toggle`, sempre visível, mesma posição de um ` já herda o estilo padrão de .modal-field input (components.css); - aqui só cuida do posicionamento (ocupa o espaço de um já herda o estilo padrão de .modal-field input + (components.css); aqui só cuida do posicionamento (ocupa o espaço de um + nativo. */ +.ips-combo__clear { + right: 30px; +} + +.ips-combo__toggle { + right: var(--space-2); +} + +.ips-combo__clear:hover, +.ips-combo__toggle:hover { + color: var(--text-primary); +} + +.ips-combo__toggle:disabled { + opacity: 0.35; + cursor: not-allowed; +} + .ips-combo__list { position: absolute; top: calc(100% + 4px); @@ -282,28 +314,7 @@ color: var(--text-muted); } -.ips-regra-observacoes { - margin: var(--space-2) 0 0; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - background: var(--bg-canvas); - border: 1px solid var(--border-subtle); - font-size: 0.82rem; - color: var(--text-muted); - white-space: pre-wrap; -} - -/* "Salvar regra atual..." fica no final do formulário (não mais junto de - Aplicar/Limpar/Ver regras salvas no topo) — salvar só faz sentido depois - de parametrizar o custeio abaixo, é o último passo do fluxo de - parametrização de uma regra nova. */ -.ips-regra-salvar-field { - margin-top: var(--space-5); - padding-top: var(--space-5); - border-top: 1px solid var(--border-subtle); -} - -/* Lista de regras salvas (#ips-regras-modal) — reaproveita .checklist-box +/* Lista de regras salvas — reaproveita .checklist-box como moldura rolável, mas cada linha tem seus próprios botões de ação em vez do checkbox usado pelos outros consumidores de .checklist-box. */ .ips-regras-lista { @@ -702,6 +713,159 @@ body.is-resizing-column * { white-space: nowrap; } +/* Modal "Cadastro de Regras" (#ips-regracad-modal) — o formulário de + custeio relocado pra dentro dele (checkboxes + radios titular/dependente + de dois tipos + observações) é bem mais alto que qualquer outro modal do + app; sem isso, `.modal-card` (components.css) cresce livremente e o + conteúdo de baixo (Observações, botões de ação) fica cortado fora da + viewport em telas mais baixas/com zoom — aqui o cartão vira ele mesmo a + área rolável, em vez do overlay inteiro. */ +#ips-regracad-modal .modal-card { + max-height: 90vh; + overflow-y: auto; +} + +/* #ips-regra-empresa-modal é aberto por cima de #ips-regracad-modal (ao + clicar "Selecionar regra" dentro do formulário de custeio) — como os dois + são .modal-overlay com o mesmo z-index (components.css), a ordem no DOM + decidiria quem fica por cima, e #ips-regra-empresa-modal vem ANTES de + #ips-regracad-modal no HTML, então ficava escondido atrás dele. Sobe o + z-index explicitamente pra sempre renderizar acima, independente da + ordem no DOM. */ +#ips-regra-empresa-modal { + z-index: 110; +} + +/* Toolbar com os comboboxes de Empresa/Operadora (navegar) + "+ Nova + regra"; o bloco .ips-regracad-form reaproveita o mesmo + .ips-tipo-field/.ips-custeio-radios de antes, só que relocado pra dentro + deste modal. */ +/* Empresa (com o nome resolvido do Questor ao lado, quando existe) fica + sempre numa linha própria, com a Operadora numa linha abaixo — pedido + explícito do usuário, tanto aqui quanto em "+ Nova regra" abaixo, pra + não competir visualmente com o nome da empresa. */ +.ips-regracad-toolbar { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.ips-regracad-toolbar__row { + display: flex; + align-items: flex-end; + gap: var(--space-3); + flex-wrap: wrap; +} + +.ips-regracad-toolbar .modal-field { + margin-bottom: 0; + flex: 1; + min-width: 220px; +} + +.ips-regracad-toolbar__row .btn-outline { + flex-shrink: 0; +} + +/* Indica claramente se o formulário abaixo está editando uma regra + existente ou criando uma nova — pedido explícito do usuário, pra não + confundir os dois modos (ver regracadCarregarParaEdicao()/ + regracadEntrarModoNovo() em importacao-plano-saude.js). */ +.ips-regracad-modo { + margin: 0 0 var(--space-3); + font-size: 0.85rem; + font-weight: 700; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); +} + +.ips-regracad-modo--editando { + color: var(--accent); +} + +.ips-regracad-modo--novo { + color: var(--gold); +} + +.ips-regracad-novo { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-bottom: var(--space-4); + margin-bottom: var(--space-4); + border-bottom: 1px solid var(--border-subtle); +} + +.ips-regracad-novo .modal-field { + flex: 1; + min-width: 220px; +} + +.ips-regracad-novo__empresa-row { + display: flex; + align-items: flex-end; + gap: var(--space-3); + flex-wrap: wrap; +} + +/* Nome da empresa resolvido no Questor (ver empresas_questor.py), exibido + ao lado do campo "Código da empresa" — os três estados (buscando/ + encontrado/não encontrado) só diferem na cor do texto. */ +.ips-regracad-novo__empresa-nome { + margin: 0 0 10px; + font-size: 0.88rem; + color: var(--text-secondary); + flex: 1; + min-width: 220px; +} + +.ips-regracad-novo__empresa-nome--buscando { + color: var(--text-muted); +} + +.ips-regracad-novo__empresa-nome--nao-encontrada { + color: var(--text-muted); + font-style: italic; +} + +/* Resumo só-leitura da regra resolvida em "Nova Importação" — nenhum campo + editável aqui, só texto explicando o que a regra cadastrada configura. */ +.ips-imp-resumo { + margin: var(--space-4) 0; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.ips-imp-resumo__tipos { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.ips-imp-resumo__linha { + margin: 0; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.ips-imp-resumo__obs { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted); + white-space: pre-wrap; +} + .ips-vincular-resumo { font-size: 0.85rem; color: var(--text-secondary); diff --git a/static/js/importacao-plano-saude.js b/static/js/importacao-plano-saude.js index 7f7e3a3..2ab3206 100644 --- a/static/js/importacao-plano-saude.js +++ b/static/js/importacao-plano-saude.js @@ -137,6 +137,10 @@ async function pidExcluirRegraCusteioPlanoSaude(id) { return pidApiRequest(`/regras-custeio-plano-saude/${id}/`, { method: "DELETE" }); } +async function pidBuscarNomeEmpresaPlanoSaude(codigoEmpresa) { + return pidApiRequest(`/regras-custeio-plano-saude/nome-empresa/?codigo_empresa=${encodeURIComponent(codigoEmpresa)}`); +} + /** * Resposta de /gerar/ é binária (CSV ou ZIP), não JSON — por isso não passa * por pidApiRequest. Reaproveita pidEnsureCsrfCookie/pidGetCookie/pidErrorMessageFrom @@ -206,9 +210,6 @@ document.addEventListener("DOMContentLoaded", async () => { const newBtn = document.getElementById("ips-new-btn"); const formOperadora = document.getElementById("ips-form-operadora"); - const ipsOperadoraCombo = document.getElementById("ips-operadora-combo"); - const ipsOperadoraSearch = document.getElementById("ips-operadora-search"); - const ipsOperadoraComboList = document.getElementById("ips-operadora-combo-list"); const formPlanilha = document.getElementById("ips-form-planilha"); const formPlanilhaName = document.getElementById("ips-form-planilha-name"); const formPlanilhaRemoveBtn = document.getElementById("ips-form-planilha-remove"); @@ -229,33 +230,66 @@ document.addEventListener("DOMContentLoaded", async () => { 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"); - const ipsRegraCombo = document.getElementById("ips-regra-combo"); - const ipsRegraSearch = document.getElementById("ips-regra-search"); - const ipsRegraComboList = document.getElementById("ips-regra-combo-list"); - const ipsRegraAplicarBtn = document.getElementById("ips-regra-aplicar-btn"); - const ipsRegraLimparBtn = document.getElementById("ips-regra-limpar-btn"); - const ipsRegraSalvarBtn = document.getElementById("ips-regra-salvar-btn"); - const ipsRegraObservacoes = document.getElementById("ips-regra-observacoes"); - const regrasGerenciarBtn = document.getElementById("ips-regras-gerenciar-btn"); - const regrasModal = document.getElementById("ips-regras-modal"); - const regrasLista = document.getElementById("ips-regras-lista"); - const regrasError = document.getElementById("ips-regras-error"); - const regrasFecharBtn = document.getElementById("ips-regras-fechar-btn"); - const regraSaveModal = document.getElementById("ips-regra-save-modal"); - const regraSaveTitle = document.getElementById("ips-regra-save-title"); - const regraSaveNome = document.getElementById("ips-regra-save-nome"); - const regraSaveObservacoes = document.getElementById("ips-regra-save-observacoes"); - const regraSaveAtualizarField = document.getElementById("ips-regra-save-atualizar-field"); - const regraSaveAtualizar = document.getElementById("ips-regra-save-atualizar"); - const regraSaveAtualizarLabel = document.getElementById("ips-regra-save-atualizar-label"); - const regraSaveError = document.getElementById("ips-regra-save-error"); - const regraSaveCancelBtn = document.getElementById("ips-regra-save-cancel-btn"); - const regraSaveConfirmBtn = document.getElementById("ips-regra-save-confirm-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 regracadConfirmFecharModal = document.getElementById("ips-regracad-confirm-fechar-modal"); + const regracadConfirmFecharCancelarBtn = document.getElementById("ips-regracad-confirm-fechar-cancelar-btn"); + const regracadConfirmFecharOkBtn = document.getElementById("ips-regracad-confirm-fechar-ok-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"); @@ -297,23 +331,28 @@ document.addEventListener("DOMContentLoaded", async () => { let vincularLinhaSelecionada = null; let operadorasCache = []; // Regras empresa (portal_api.planos_saude.regras_empresa) — registro fixo - // no código, sem relação com o banco de regras de custeio salvas acima; - // regraEmpresaSelecionada guarda {key, label} da regra atualmente - // escolhida no checkbox "Regra empresa" (ver ips-regra-empresa-modal). + // no código; regraEmpresaSelecionada guarda {key, label} da regra + // atualmente escolhida no checkbox "Mensalidade usa regra especial" (ver + // ips-regra-empresa-modal), dentro do formulário de Cadastro de Regras. let regrasEmpresaCache = []; let regraEmpresaSelecionada = null; - // Banco de regras de custeio salvas (ver "Regra de custeio salva" no - // formulário) — regraAplicadaId rastreia qual regra está atualmente - // refletida no formulário (aplicada via select ou recém-salva), pra - // "Salvar regra atual..." nascer em modo "atualizar" por padrão nesse caso. + // 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 = []; - let regraAplicadaId = null; - // Id atualmente escolhido no combobox pesquisável (#ips-regra-search) — - // diferente de regraAplicadaId: este só reflete o que está selecionado - // na caixa de busca; regraAplicadaId reflete o que está de fato refletido - // nos campos do formulário (pode ficar desatualizado em relação ao combo - // se o usuário digitar algo novo sem clicar em "Aplicar"). - let regraSelecionadaId = null; + // 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; // 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, @@ -563,10 +602,9 @@ document.addEventListener("DOMContentLoaded", async () => { renderRegraEmpresaAtual(); } - // Zera só a parte de tipo/custeio do formulário (tipos_lancamento + modo de - // custeio/limite/percentual de cada combinação tipo×pessoa) — extraído de - // resetForm() pra ser reaproveitado por "Limpar seleção" (desfaz o que uma - // regra aplicada por engano preencheu, sem mexer nos arquivos já anexados). + // 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; formTipoCoparticipacao.checked = false; @@ -588,26 +626,6 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - // Limpa só o rastreamento de "regra selecionada/aplicada" (combobox + - // observações), sem mexer no custeio do formulário — usado por resetForm() - // e sozinho pelo botão "Limpar seleção" (junto com limparCusteioForm()). - function limparRegraSelecionada() { - regraAplicadaId = null; - regraSelecionadaId = null; - if (ipsRegraSearch) ipsRegraSearch.value = ""; - fecharRegraComboList(); - mostrarObservacoesRegra(null); - } - - function resetForm() { - validadorPlanilha.limpar(); - validadorArquivo.limpar(); - limparCusteioForm(); - limparRegraSelecionada(); - limparOperadoraSelecionada(); - formError.textContent = ""; - } - // "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. @@ -683,10 +701,18 @@ document.addEventListener("DOMContentLoaded", async () => { // backend (limite_valor/percentual como float, não texto BR). function aplicarCusteio(regra) { const tipos = Array.isArray(regra.tipos_lancamento) ? regra.tipos_lancamento : []; - // Regra de custeio salva sempre configura mensalidade manualmente — - // mutuamente exclusivo com "Regra empresa" (ver limparRegraEmpresa()). + // Repõe o custeio de uma regra dentro do formulário de Cadastro de + // Regras — mutuamente exclusivo entre "mensalidade manual" e "mensalidade + // usa regra especial" (ver limparRegraEmpresa()). limparRegraEmpresa(); - formTipoMensalidade.checked = tipos.includes("mensalidade"); + 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 }; + if (formTipoRegraEmpresa) formTipoRegraEmpresa.checked = true; + if (regraEmpresaBox) regraEmpresaBox.hidden = false; + renderRegraEmpresaAtual(); + } + formTipoMensalidade.checked = tipos.includes("mensalidade") && !regra.regra_empresa_chave; custeioMensalidade.hidden = !formTipoMensalidade.checked; formTipoCoparticipacao.checked = tipos.includes("coparticipacao"); custeioCoparticipacao.hidden = !formTipoCoparticipacao.checked; @@ -722,102 +748,268 @@ document.addEventListener("DOMContentLoaded", async () => { return operadora ? operadora.label : key; } - // Combobox pesquisável de "Operadora" (#ips-operadora-combo) — mesmo - // espírito do combobox de "Regra de custeio salva" logo abaixo (campo de - // busca dobrando como "display" do valor selecionado), reaproveitando as - // mesmas classes .ips-combo* — mas aqui o valor de fato submetido viaja - // num (`formOperadora`), já que ao contrário da - // regra a operadora É um campo do formulário, sem um botão "Aplicar" - // separado: escolher um item da lista já grava o valor na hora. - function fecharOperadoraComboList() { - if (ipsOperadoraComboList) { - ipsOperadoraComboList.hidden = true; - ipsOperadoraComboList.innerHTML = ""; + // 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"); + } + + // " - " 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) => `
${escapeHtml(it.label)}
`) + .join("") + : `

Nenhum resultado encontrado.

`; + list.hidden = false; + } + + function render(filtro) { + const termo = (filtro || "").trim().toLowerCase(); + renderLista(termo ? itens.filter((it) => it.label.toLowerCase().includes(termo)) : itens); + } + + // Botão "x" embutido na própria barra do combobox (dentro de + // .ips-combo) — só aparece quando há algum texto (digitado ou + // selecionado) pra limpar. + function atualizarClearBtn() { + if (clearBtn) clearBtn.hidden = !(input && input.value.trim()); + } + + function selecionar(item, { silencioso } = {}) { + if (input) input.value = item ? item.label : ""; + atualizarClearBtn(); + fechar(); + if (!silencioso && onChange) onChange(item || null); + } + + if (input) { + input.addEventListener("focus", () => render(input.value)); + input.addEventListener("input", () => { + render(input.value); + atualizarClearBtn(); + if (onChange) onChange(null); + }); + } + if (list) { + list.addEventListener("click", (event) => { + const el = event.target.closest("[data-combo-key]"); + if (!el) return; + const item = itens.find((it) => String(it.key) === el.getAttribute("data-combo-key")); + if (item) selecionar(item); + }); + } + if (clearBtn) { + // Diferente de limpar() (silencioso, usado só por quem já vai tratar + // a cascata na mão) — clicar no "x" precisa disparar onChange(null) + // normalmente, pra "limpar Empresa também limpa Operadora" continuar + // funcionando (ver regracadEmpresaOps.onChange). + clearBtn.addEventListener("click", () => { + selecionar(null); + if (input) input.focus(); + }); + atualizarClearBtn(); + } + if (toggleBtn) { + // Botão embutido na barra, ao lado do "x" — pedido explícito do + // usuário: mostra a lista completa igual a quando o campo está vazio; + // se já houver algo selecionado, mostra as OUTRAS opções cadastradas + // (sem repetir a já escolhida), já que só focar o campo com um valor + // preenchido filtraria pra mostrar de novo só o item já selecionado. + toggleBtn.addEventListener("click", () => { + if (input) input.focus(); + const atual = input ? input.value.trim() : ""; + renderLista(atual ? itens.filter((it) => it.label !== atual) : itens); + }); + } + + return { + setItens(novos) { + itens = novos || []; + }, + selecionar, + limpar() { + selecionar(null, { silencioso: true }); + }, + fechar, + contains(el) { + return !!comboEl && comboEl.contains(el); + }, + }; + } + + const impOperadoraOps = criarComboboxTexto({ + input: impOperadoraSearch, + list: impOperadoraList, + clearBtn: impOperadoraLimparBtn, + toggleBtn: impOperadoraToggleBtn, + onChange: (item) => { + formOperadora.value = item ? item.key : ""; + const empresaAtual = impEmpresaValor.value; + regraResolvidaAtual = item + ? regras.find((r) => r.codigo_empresa === empresaAtual && r.operadora === item.key) || null + : null; + renderResumoRegra(regraResolvidaAtual); + if (item) validadorArquivo.revalidarSeAnexado(); + }, + }); + + const impEmpresaOps = criarComboboxTexto({ + input: impEmpresaSearch, + list: impEmpresaList, + clearBtn: impEmpresaLimparBtn, + toggleBtn: impEmpresaToggleBtn, + onChange: (item) => { + impEmpresaValor.value = item ? item.key : ""; + impOperadoraOps.limpar(); + if (item) { + impOperadoraOps.setItens(operadorasComRegraParaEmpresa(item.key)); + if (impOperadoraSearch) impOperadoraSearch.disabled = false; + if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = false; + } else { + impOperadoraOps.setItens([]); + if (impOperadoraSearch) impOperadoraSearch.disabled = true; + if (impOperadoraToggleBtn) impOperadoraToggleBtn.disabled = true; + } + }, + }); + + // Explica o custeio de uma regra em texto simples pro resumo só-leitura + // de "Nova Importação" — nunca editável ali, só exibido. + function resumoModoPessoa(entrada) { + if (!entrada || !entrada.modo) return "não configurado"; + if (entrada.modo === "empresa") return "custeado pela empresa"; + if (entrada.modo === "empregado") return "descontado do empregado"; + if (entrada.modo === "especifica") { + const partes = []; + if (entrada.limite_valor !== null && entrada.limite_valor !== undefined) { + partes.push(`limite de R$ ${formatarNumeroBr(entrada.limite_valor)}`); + } + if (entrada.percentual !== null && entrada.percentual !== undefined) { + partes.push(`${formatarNumeroBr(entrada.percentual)}% custeado pela empresa`); + } + return `regra específica (${partes.join(", ") || "sem detalhe"})`; + } + return entrada.modo; + } + + function renderResumoRegra(regra) { + if (!impResumo) return; + if (!regra) { + impResumo.hidden = true; + return; + } + const tipos = regra.tipos_lancamento || []; + const custeioPorTipo = regra.custeio_por_tipo || {}; + impResumo.hidden = false; + if (impResumoTipos) impResumoTipos.textContent = `Tipos cobertos por esta regra: ${tiposLabel(tipos)}`; + + if (impResumoMensalidade) { + if (tipos.includes("mensalidade")) { + if (regra.regra_empresa_chave) { + 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")) { + 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; + } } } - function renderOperadoraComboList(filtro) { - if (!ipsOperadoraComboList) return; - const termo = (filtro || "").trim().toLowerCase(); - const filtradas = termo - ? operadorasCache.filter((op) => op.label.toLowerCase().includes(termo)) - : operadorasCache; - - if (!filtradas.length) { - ipsOperadoraComboList.innerHTML = `

Nenhuma operadora encontrada.

`; - } else { - ipsOperadoraComboList.innerHTML = filtradas - .map((op) => `
${escapeHtml(op.label)}
`) - .join(""); + // Monta o FormData de "Nova Importação" a partir da regra resolvida — + // mesmo formato de campos que ImportacaoPlanoSaudeCreateSerializer já + // espera (custeio__, 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); } - ipsOperadoraComboList.hidden = false; - } - - function selecionarOperadora(op) { - formOperadora.value = op.key; - if (ipsOperadoraSearch) ipsOperadoraSearch.value = op.label; - fecharOperadoraComboList(); - validadorArquivo.revalidarSeAnexado(); - } - - function limparOperadoraSelecionada() { - formOperadora.value = ""; - if (ipsOperadoraSearch) ipsOperadoraSearch.value = ""; - fecharOperadoraComboList(); - } - - function mostrarObservacoesRegra(regra) { - if (regra && regra.observacoes) { - ipsRegraObservacoes.textContent = regra.observacoes; - ipsRegraObservacoes.hidden = false; - } else if (ipsRegraObservacoes) { - ipsRegraObservacoes.hidden = true; - ipsRegraObservacoes.textContent = ""; - } - } - - // Aplica a regra no formulário (custeio + operadora, se ela ainda existir - // no - // simples (deixou de escalar visualmente com o crescimento do banco de - // regras salvas). - function fecharRegraComboList() { - if (ipsRegraComboList) { - ipsRegraComboList.hidden = true; - ipsRegraComboList.innerHTML = ""; - } - } - - function renderRegraComboList(filtro) { - if (!ipsRegraComboList) return; - const termo = (filtro || "").trim().toLowerCase(); - const filtradas = termo ? regras.filter((r) => r.nome.toLowerCase().includes(termo)) : regras; - - if (!filtradas.length) { - ipsRegraComboList.innerHTML = `

Nenhuma regra encontrada.

`; - } else { - ipsRegraComboList.innerHTML = filtradas - .map((r) => `
${escapeHtml(r.nome)}
`) - .join(""); - } - ipsRegraComboList.hidden = false; + (regra.tipos_lancamento || []).forEach((tipo) => { + if (tipo === "mensalidade" && regra.regra_empresa_chave) 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)); + } + }); + }); } async function refreshRegras() { @@ -826,40 +1018,6 @@ document.addEventListener("DOMContentLoaded", async () => { } catch (e) { regras = []; } - // Se a regra selecionada no combobox foi excluída em outro lugar - // enquanto isso (ex.: pelo modal "Ver regras salvas"), limpa a seleção - // em vez de deixar o campo de busca mostrando um nome que não existe mais. - if (regraSelecionadaId !== null && !regras.some((r) => r.id === regraSelecionadaId)) { - regraSelecionadaId = null; - if (ipsRegraSearch) ipsRegraSearch.value = ""; - } - } - - function renderRegrasLista() { - if (!regrasLista) return; - if (!regras.length) { - regrasLista.innerHTML = `

Nenhuma regra salva ainda.

`; - return; - } - regrasLista.innerHTML = regras - .map((r) => { - const meta = `${r.operadora ? operadoraLabel(r.operadora) : "Qualquer operadora"} · ${tiposLabel(r.tipos_lancamento)}`; - return ` -
-
-

${escapeHtml(r.nome)}

-

${escapeHtml(meta)}

- ${r.observacoes ? `

${escapeHtml(r.observacoes)}

` : ""} -
-
- - -
-
`; - }) - .join(""); } // Lista do modal "Selecionar regra empresa" — registro fixo em @@ -886,26 +1044,256 @@ document.addEventListener("DOMContentLoaded", async () => { .join(""); } - function abrirRegraSaveModal() { - regraSaveError.textContent = ""; - const regraBase = regras.find((r) => r.id === regraAplicadaId) || null; - regraSaveNome.value = regraBase ? regraBase.nome : ""; - regraSaveObservacoes.value = regraBase ? regraBase.observacoes || "" : ""; - if (regraBase) { - regraSaveAtualizarField.hidden = false; - regraSaveAtualizar.checked = true; - regraSaveAtualizarLabel.textContent = `Atualizar a regra selecionada ("${regraBase.nome}")`; - } else { - regraSaveAtualizarField.hidden = true; - regraSaveAtualizar.checked = false; + // 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; } - regraSaveTitle.textContent = regraBase ? "Salvar regra de custeio" : "Salvar nova regra de custeio"; - regraSaveModal.hidden = false; - regraSaveNome.focus(); + regracadModo.hidden = false; + regracadModo.textContent = texto; + regracadModo.className = `ips-regracad-modo ${classeExtra}`; } - async function openNewForm() { - resetForm(); + 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 no Questor."; + } + // 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; + if (regracadConfirmFecharModal) regracadConfirmFecharModal.hidden = true; + 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) { @@ -917,12 +1305,54 @@ document.addEventListener("DOMContentLoaded", async () => { regrasEmpresaCache = []; } await refreshRegras(); + regracadEmpresaOps.setItens(empresasComRegra()); + regracadMostrarVazio(); + } + + function fecharCadastroRegras() { + regracadModal.hidden = true; + } + + async function openNewForm() { + validadorPlanilha.limpar(); + validadorArquivo.limpar(); + formError.textContent = ""; + 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}` : ""; reviewSubtitle.textContent = `${importacao.nome_operadora} — criada em ${new Date(importacao.criado_em).toLocaleString("pt-BR")}${regraEmpresaSufixo}`; @@ -1306,26 +1736,56 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - if (ipsOperadoraSearch) { - ipsOperadoraSearch.addEventListener("focus", () => renderOperadoraComboList(ipsOperadoraSearch.value)); - ipsOperadoraSearch.addEventListener("input", () => { - // Mesmo cuidado do combobox de "Regra de custeio salva": digitar de - // novo invalida a seleção anterior até o usuário clicar num item da - // lista, pra nunca submeter uma operadora que não bate mais com o - // texto exibido no campo. - formOperadora.value = ""; - renderOperadoraComboList(ipsOperadoraSearch.value); + if (regracadBtn) regracadBtn.addEventListener("click", abrirCadastroRegras); + if (regracadFecharBtn) regracadFecharBtn.addEventListener("click", fecharCadastroRegras); + if (regracadModal) { + regracadModal.addEventListener("click", (event) => { + // 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. Usa um modal próprio + // (mesmo padrão visual de .modal-overlay/.modal-card do resto do + // app) em vez de window.confirm() — o diálogo nativo do browser + // aparece com a URL do servidor ("192.168.x.x:8000 diz..."), fora do + // visual do Portal. + if (event.target === regracadModal && regracadConfirmFecharModal) { + regracadConfirmFecharModal.hidden = false; + } }); } - if (ipsOperadoraComboList) { - ipsOperadoraComboList.addEventListener("click", (event) => { - const item = event.target.closest("[data-operadora-key]"); - if (!item) return; - const op = operadorasCache.find((o) => o.key === item.getAttribute("data-operadora-key")); - if (op) selecionarOperadora(op); + if (regracadConfirmFecharCancelarBtn) { + regracadConfirmFecharCancelarBtn.addEventListener("click", () => { + regracadConfirmFecharModal.hidden = true; }); } + if (regracadConfirmFecharOkBtn) { + regracadConfirmFecharOkBtn.addEventListener("click", () => { + regracadConfirmFecharModal.hidden = true; + fecharCadastroRegras(); + }); + } + if (regracadConfirmFecharModal) { + // Clicar fora do próprio diálogo de confirmação cancela (não fecha o + // cadastro) — mesmo espírito conservador do resto do app. + regracadConfirmFecharModal.addEventListener("click", (event) => { + if (event.target === regracadConfirmFecharModal) regracadConfirmFecharModal.hidden = true; + }); + } + 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", () => { @@ -1344,10 +1804,7 @@ document.addEventListener("DOMContentLoaded", async () => { formTipoRegraEmpresa.addEventListener("change", () => { if (regraEmpresaBox) regraEmpresaBox.hidden = !formTipoRegraEmpresa.checked; if (formTipoRegraEmpresa.checked) { - // Mesma exclusividade mútua do outro lado (formTipoMensalidade acima) - // — também limpa o rastreamento de "regra de custeio salva" aplicada, - // senão o submit enviaria os dois ao mesmo tempo pro backend. - limparRegraSelecionada(); + // Mesma exclusividade mútua do outro lado (formTipoMensalidade acima). formTipoMensalidade.checked = false; custeioMensalidade.hidden = true; custeioMensalidade.querySelectorAll('input[type="radio"]').forEach((r) => (r.checked = false)); @@ -1383,158 +1840,95 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - if (ipsRegraSearch) { - ipsRegraSearch.addEventListener("focus", () => renderRegraComboList(ipsRegraSearch.value)); - ipsRegraSearch.addEventListener("input", () => { - // Digitar de novo invalida a seleção anterior — só volta a valer - // depois de clicar numa regra da lista (evita "Aplicar" usar uma - // regra antiga enquanto o texto mostrado já é outra busca). - regraSelecionadaId = null; - renderRegraComboList(ipsRegraSearch.value); - }); - } - - if (ipsRegraComboList) { - ipsRegraComboList.addEventListener("click", (event) => { - const item = event.target.closest("[data-regra-id]"); - if (!item) return; - const id = Number(item.getAttribute("data-regra-id")); - const regra = regras.find((r) => r.id === id); - if (!regra) return; - regraSelecionadaId = regra.id; - ipsRegraSearch.value = regra.nome; - fecharRegraComboList(); - mostrarObservacoesRegra(regra); - }); - } - - document.addEventListener("click", (event) => { - if (ipsRegraCombo && !ipsRegraCombo.contains(event.target)) fecharRegraComboList(); - if (ipsOperadoraCombo && !ipsOperadoraCombo.contains(event.target)) fecharOperadoraComboList(); - }); - - if (ipsRegraAplicarBtn) { - ipsRegraAplicarBtn.addEventListener("click", () => { - if (!regraSelecionadaId) { - formError.textContent = "Selecione uma regra salva pra aplicar."; - return; - } - const regra = regras.find((r) => r.id === regraSelecionadaId); - if (regra) aplicarRegraNoFormulario(regra); - }); - } - - if (ipsRegraLimparBtn) { - ipsRegraLimparBtn.addEventListener("click", () => { - limparRegraSelecionada(); - limparCusteioForm(); - limparOperadoraSelecionada(); - formError.textContent = ""; - }); - } - - if (ipsRegraSalvarBtn) { - ipsRegraSalvarBtn.addEventListener("click", () => { - const { tipos_lancamento } = coletarCusteioAtual(); + if (regracadSalvarBtn) { + regracadSalvarBtn.addEventListener("click", async () => { + if (!regracadRegraEmEdicao) return; + const { tipos_lancamento, custeio_por_tipo } = coletarCusteioAtual(); const erro = mensagemErroCusteio(tipos_lancamento); if (erro) { - formError.textContent = erro; + regracadError.textContent = erro; return; } - formError.textContent = ""; - abrirRegraSaveModal(); + 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 (regraSaveCancelBtn) regraSaveCancelBtn.addEventListener("click", () => (regraSaveModal.hidden = true)); - if (regraSaveModal) { - regraSaveModal.addEventListener("click", (event) => { - if (event.target === regraSaveModal) regraSaveModal.hidden = true; + if (regracadExcluirBtn) { + regracadExcluirBtn.addEventListener("click", async () => { + if (!regracadRegraEmEdicao) return; + if (!window.confirm(`Excluir a regra "${regracadRegraEmEdicao.nome}"? Essa ação não pode ser desfeita.`)) 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 (regraSaveConfirmBtn) { - regraSaveConfirmBtn.addEventListener("click", async () => { - const nome = regraSaveNome.value.trim(); - if (!nome) { - regraSaveError.textContent = "Informe um nome para a regra."; + 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 payload = { - nome, - operadora: formOperadora.value || "", - tipos_lancamento, - custeio_por_tipo, - observacoes: regraSaveObservacoes.value.trim(), - }; - const atualizando = !regraSaveAtualizarField.hidden && regraSaveAtualizar.checked && regraAplicadaId; - - regraSaveConfirmBtn.disabled = true; - try { - const regraSalva = atualizando - ? await pidAtualizarRegraCusteioPlanoSaude(regraAplicadaId, payload) - : await pidCriarRegraCusteioPlanoSaude(payload); - await refreshRegras(); - regraAplicadaId = regraSalva.id; - regraSelecionadaId = regraSalva.id; - if (ipsRegraSearch) ipsRegraSearch.value = regraSalva.nome; - mostrarObservacoesRegra(regraSalva); - regraSaveModal.hidden = true; - } catch (e) { - regraSaveError.textContent = e.message; - } finally { - regraSaveConfirmBtn.disabled = false; - } - }); - } - - if (regrasGerenciarBtn) { - regrasGerenciarBtn.addEventListener("click", async () => { - regrasError.textContent = ""; - await refreshRegras(); - renderRegrasLista(); - regrasModal.hidden = false; - }); - } - - if (regrasFecharBtn) regrasFecharBtn.addEventListener("click", () => (regrasModal.hidden = true)); - if (regrasModal) { - regrasModal.addEventListener("click", (event) => { - if (event.target === regrasModal) regrasModal.hidden = true; - }); - } - - if (regrasLista) { - regrasLista.addEventListener("click", async (event) => { - const aplicarBtn = event.target.closest("[data-regra-aplicar]"); - if (aplicarBtn) { - const id = Number(aplicarBtn.getAttribute("data-regra-aplicar")); - const regra = regras.find((r) => r.id === id); - if (regra) { - aplicarRegraNoFormulario(regra); - regrasModal.hidden = true; - } + const erro = mensagemErroCusteio(tipos_lancamento); + if (erro) { + regracadError.textContent = erro; return; } - - const excluirBtn = event.target.closest("[data-regra-excluir]"); - if (excluirBtn) { - const id = Number(excluirBtn.getAttribute("data-regra-excluir")); - const regra = regras.find((r) => r.id === id); - if (!regra) return; - if (!window.confirm(`Excluir a regra "${regra.nome}"? Essa ação não pode ser desfeita.`)) return; - excluirBtn.disabled = true; - try { - await pidExcluirRegraCusteioPlanoSaude(id); - if (regraAplicadaId === id) regraAplicadaId = null; - await refreshRegras(); - renderRegrasLista(); - regrasError.textContent = ""; - } catch (e) { - regrasError.textContent = e.message; - excluirBtn.disabled = false; - } + 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; } }); } @@ -1777,10 +2171,8 @@ document.addEventListener("DOMContentLoaded", async () => { if (formSubmitBtn) { formSubmitBtn.addEventListener("click", async () => { - const { tipos_lancamento: tipos, custeio_por_tipo: custeio } = coletarCusteioAtual(); - - if (!formOperadora.value) { - formError.textContent = "Selecione a operadora."; + if (!regraResolvidaAtual) { + formError.textContent = "Selecione a empresa e a operadora."; return; } if (!formPlanilha.files[0]) { @@ -1795,39 +2187,12 @@ document.addEventListener("DOMContentLoaded", async () => { formError.textContent = "Corrija os arquivos anexados (veja a mensagem abaixo de cada um) antes de processar."; return; } - const erroCusteio = mensagemErroCusteio(tipos); - if (erroCusteio) { - formError.textContent = erroCusteio; - return; - } formError.textContent = ""; const formData = new FormData(); - formData.append("operadora", formOperadora.value); formData.append("planilha_padrao", formPlanilha.files[0]); formData.append("arquivo_operadora", formArquivo.files[0]); - formData.append("tipos_lancamento", tipos.join(",")); - if (formTipoRegraEmpresa.checked && regraEmpresaSelecionada) { - formData.append("regra_empresa", regraEmpresaSelecionada.key); - } else if (regraAplicadaId) { - // Só registro informativo (ver ImportacaoPlanoSaude.regra_custeio_salva) - // — não influencia o processamento, só permite mostrar a observação - // da regra de custeio salva aplicada na tela de Revisão. - formData.append("regra_custeio_salva", regraAplicadaId); - } - tipos.forEach((tipo) => { - // "Regra empresa" cobre mensalidade sozinha — sem titular/dependente - // pra enviar aqui (ver coletarCusteioAtual()). - if (tipo === "mensalidade" && formTipoRegraEmpresa.checked) return; - PID_IPS_PESSOAS.forEach((pessoa) => { - const entrada = custeio[tipo][pessoa]; - formData.append(`custeio_${tipo}_${pessoa}`, entrada.modo); - if (entrada.modo === "especifica") { - formData.append(`limite_valor_${tipo}_${pessoa}`, entrada.limite_valor || ""); - formData.append(`percentual_${tipo}_${pessoa}`, entrada.percentual || ""); - } - }); - }); + montarFormDataDeRegra(formData, regraResolvidaAtual); formSubmitBtn.disabled = true; formSubmitBtn.textContent = "Processando..."; diff --git a/templates/importacao-plano-saude.html b/templates/importacao-plano-saude.html index 023134b..019f524 100644 --- a/templates/importacao-plano-saude.html +++ b/templates/importacao-plano-saude.html @@ -301,10 +301,16 @@

Importação de Plano de Saúde

Gere o arquivo de lançamento de mensalidade/coparticipação no leiaute do sistema, a partir do relatório da operadora.

- +
+ + +
-