/** * Controlador de "Conciliação de Fornecedores" (Utilitários), ver CLAUDE.md * do pacote `portal_api/conciliacao_fornecedores/`. Guarda de página no * mesmo padrão de `nao-conformidades.js`: retorna cedo se o elemento raiz da * tela não existir e some atrás de `#conc-no-access` sem * `permissoes_efetivas.utilitarios.apps["conciliacao-fornecedores"]`. * * Duas vistas na mesma página: histórico (+ formulário de nova conciliação) * e detalhe de uma conciliação (cards de alerta + tabela de fornecedores * expansível). O detalhe abre por `#conciliacao-` na URL, para um * recarregamento voltar à mesma conciliação. */ const PID_CONC_MOEDA = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }); // "Pendente de pagamento" deixou de existir: só títulos em aberto, sem // pagamento a conciliar, é conciliada (pedido do usuário). const PID_CONC_STATUS = { divergente: { rotulo: "Crítico", pill: "status-pill--danger", rank: 0 }, atencao: { rotulo: "A validar", pill: "status-pill--warning", rank: 1 }, // sem alerta, com saldo de pagamento parcial ainda a pagar parcial: { rotulo: "Parcial", pill: "status-pill--parcial", rank: 2 }, conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 3 }, }; /** Vínculo de pagamento parcial com saldo ainda a pagar. */ function pidConcTemParcialAberto(conta) { return conta.vinculos.some((v) => v.tipo === "parcial" && Number(v.diferenca) > 0); } // Chips da lista de fornecedores (pedido do usuário, rodada 65 do // CHANGELOG): agrupam pela validação, não pela situação calculada. // Parcial, Saldo em aberto e Conciliados dividem as validadas. const PID_CONC_GRUPOS = { pendentes: (c) => !c.validada, parcial: (c) => c.validada && pidConcTemParcialAberto(c), saldo_aberto: (c) => c.validada && !pidConcTemParcialAberto(c) && Number(c.saldo_final) !== 0, conciliados: (c) => c.validada && !pidConcTemParcialAberto(c) && Number(c.saldo_final) === 0, validados: (c) => c.validada, }; // Ajuste de cada vínculo manual com diferença (pedido do usuário, rodada 74 // do CHANGELOG): parcelamento = pagamento parcial com saldo a pagar; juros = // pago a maior; desconto = pago a menor, fechado. Derivado de tipo + // diferença (créditos − débitos), nada gravado à parte; o contador escolhe // entre parcelamento e desconto ao vincular (`fechar` no vincular/adicionar). const PID_CONC_AJUSTES = { parcelamento: { rotulo: "Parcelamento", icone: '', }, juros: { rotulo: "Juros", icone: '', }, desconto: { rotulo: "Desconto", icone: '', }, }; function pidConcAjusteVinculo(vinculo) { const diferenca = Number(vinculo.diferenca); if (vinculo.tipo === "parcial") return diferenca > 0 ? "parcelamento" : null; if (vinculo.tipo !== "manual" && vinculo.tipo !== "tolerancia") return null; if (diferenca < 0) return "juros"; return diferenca > 0 ? "desconto" : null; } /** Valor por ajuste da conta ({parcelamento: 4327.1, ...}), só os presentes. */ function pidConcAjustesConta(conta) { const total = {}; conta.vinculos.forEach((v) => { const ajuste = pidConcAjusteVinculo(v); if (ajuste) total[ajuste] = (total[ajuste] || 0) + Math.abs(Number(v.diferenca)); }); return total; } function pidConcAjustesHtml(conta) { const ajustes = pidConcAjustesConta(conta); const itens = Object.keys(PID_CONC_AJUSTES) .filter((chave) => ajustes[chave]) .map((chave) => { const sufixo = chave === "parcelamento" ? " a pagar" : ""; const dica = `${PID_CONC_AJUSTES[chave].rotulo}: ${pidConcMoeda(ajustes[chave])}${sufixo}`; return `${PID_CONC_AJUSTES[chave].icone}`; }); return itens.length ? itens.join("") : '—'; } // Escolha do contador quando os títulos passam dos pagamentos além da // tolerância: parcelamento (o saldo continua a pagar) ou desconto (fecha). // Modal no padrão do Portal, com três saídas (parcelamento, desconto, // cancelar), por isso não usa `pidConfirm`. Devolve a chave ou null. let pidConcEscolhaModal = null; function pidConcEscolheAjuste(mensagem, diferenca) { if (!pidConcEscolhaModal) { pidConcEscolhaModal = document.createElement("div"); pidConcEscolhaModal.className = "modal-overlay modal-overlay--top"; pidConcEscolhaModal.hidden = true; pidConcEscolhaModal.innerHTML = ` `; document.body.appendChild(pidConcEscolhaModal); } const modal = pidConcEscolhaModal; modal.querySelector("[data-escolha-mensagem]").textContent = mensagem; modal.querySelector("[data-escolha-parcelamento]").textContent = `O título continua em aberto com ${pidConcMoeda(diferenca)} a pagar nos próximos meses.`; modal.querySelector("[data-escolha-desconto]").textContent = `O vínculo fecha; os ${pidConcMoeda(diferenca)} ficam registrados como desconto.`; modal.hidden = false; return new Promise((resolve) => { function finalizar(resultado) { modal.hidden = true; modal.removeEventListener("click", onClick); resolve(resultado || null); } function onClick(evento) { if (evento.target === modal) return finalizar(null); const botao = evento.target.closest("[data-escolha]"); if (botao) finalizar(botao.dataset.escolha); } modal.addEventListener("click", onClick); }); } const PID_CONC_CHEVRON = ''; const PID_CONC_ROTULO_ALERTA = { criticos_pendentes: "Críticos pendentes", debito_excedente: "Pagamentos sem título em aberto", pagamento_sem_titulo: "Pagamentos a vincular", diferenca_vinculo: "Diferenças de juros/desconto", conta_transitoria: "Conta transitória com saldo", saldo_devedor: "Saldo devedor", saldo_divergente_arquivo: "Saldo diferente do arquivo", }; const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" }; // Delegado ao `pidEscapeHtml` (api.js), que já escapa aspas: serve para // conteúdo e para atributo. `pidConcEscapeAttr` fica como sinônimo. function pidConcEscape(texto) { return pidEscapeHtml(texto); } function pidConcEscapeAttr(texto) { return pidEscapeHtml(texto); } /** Célula de histórico em uma linha só (reticências + texto completo no * hover); a contrapartida segue na segunda linha, também cortada. */ function pidConcHistoricoHtml(lanc) { const texto = lanc.eh_saldo_anterior ? "Saldo anterior" : lanc.historico || ""; const historico = lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(texto); const contrapartida = lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""; return `${historico}${contrapartida}`; } // Tooltip do texto cortado com reticências (`[data-conc-tooltip]`), no // visual do Portal em vez do `title` nativo (pedido do usuário; mesmo // visual de `.dc-hover-tooltip__texto` do Relatório Contábil). Um único // elemento `position: fixed` no body (as tabelas cortam `absolute`), // delegado no document para valer nas linhas recriadas a cada re-render. // Só aparece quando o texto está de fato cortado, salvo com // `data-conc-tooltip-sempre` (rótulo de botão só com ícone). let pidConcTooltipEl = null; function pidConcMostraTooltip(evento) { const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]"); if (!alvo) return; // sugestão em várias linhas abre sempre: o balão mostra os lançamentos em // colunas, que a célula (numa linha só) não mostra const estruturada = alvo.classList.contains("conc-sugestao") && alvo.dataset.concTooltip.includes("\n"); if (!estruturada && !("concTooltipSempre" in alvo.dataset) && alvo.scrollWidth <= alvo.clientWidth) return; if (!pidConcTooltipEl) { pidConcTooltipEl = document.createElement("div"); pidConcTooltipEl.className = "conc-tooltip"; pidConcTooltipEl.setAttribute("role", "tooltip"); document.body.appendChild(pidConcTooltipEl); } // Variante "rica" (barra de validação): título com marcador de cor, // valor em destaque e o texto do balão como detalhe, centrada no alvo // com uma seta. const titulo = alvo.dataset.concTooltipTitulo; pidConcTooltipEl.classList.toggle("conc-tooltip--rico", Boolean(titulo)); if (titulo) { pidConcTooltipEl.innerHTML = ` ${pidConcEscape(titulo)} ${pidConcEscape(alvo.dataset.concTooltipValor || "")} ${pidConcEscape(alvo.dataset.concTooltip)}`; } else if (estruturada) { // Os lançamentos citados são do lado oposto ao da linha: na sugestão de // um título (ou do saldo a pagar), pagamentos; na de um pagamento, títulos. const linha = alvo.closest("tr"); const naturezaItens = linha && linha.querySelector(".conc-natureza--D") ? "C" : "D"; pidConcTooltipEl.innerHTML = pidConcSugestaoEstruturadaHtml(alvo.dataset.concTooltip, naturezaItens); } else { pidConcTooltipEl.textContent = alvo.dataset.concTooltip; } pidConcTooltipEl.classList.toggle("conc-tooltip--sugestao", pidConcTooltipEl.querySelector(".conc-tooltip__lanc") !== null); pidConcTooltipEl.hidden = false; const margem = 8; const rect = alvo.getBoundingClientRect(); const largura = pidConcTooltipEl.offsetWidth; const altura = pidConcTooltipEl.offsetHeight; const base = titulo ? rect.left + rect.width / 2 - largura / 2 : rect.left; const esquerda = Math.max(margem, Math.min(base, window.innerWidth - largura - margem)); let topo = rect.top - altura - margem; const abaixo = topo < margem; if (abaixo) topo = rect.bottom + margem; pidConcTooltipEl.classList.toggle("conc-tooltip--abaixo", abaixo); // posição da seta: sempre no centro do alvo, mesmo se o balão encostou // na borda da janela pidConcTooltipEl.style.setProperty("--conc-tooltip-seta", `${rect.left + rect.width / 2 - esquerda}px`); pidConcTooltipEl.style.left = `${esquerda}px`; pidConcTooltipEl.style.top = `${topo}px`; } /** Sugestão em várias linhas no balão, mais visual (pedido do usuário): a * 1ª linha é o título; cada linha de lançamento ("Seq. 37879 · 26/06/2026 · * R$ 2.163,57 · parcela 1 de 4", ver `_linha_item` em alertas.py) vira * colunas; as demais são notas, e "Vincule para confirmar." fica apagada. * O código leva a cor da natureza no razão (`naturezaItens` "C" ou "D"; * saldo a pagar em dourado), pedido do usuário. */ function pidConcSugestaoEstruturadaHtml(texto, naturezaItens) { const linhas = texto.split("\n").map((l) => l.trim()).filter(Boolean); return linhas .map((linha, i) => { if (i === 0) return `
${pidConcEscape(linha)}
`; if (/^(Seq\. |Saldo a pagar · )/.test(linha)) { const [codigo, data, valor, ...resto] = linha.split(" · "); const natureza = codigo === "Saldo a pagar" ? "saldo" : naturezaItens; return `
${pidConcEscape(codigo)} ${pidConcEscape(data || "")} ${pidConcEscape(valor || "")} ${pidConcEscape(resto.join(" · "))}
`; } const apagada = linha === "Vincule para confirmar." ? " conc-tooltip__nota--apagada" : ""; return `
${pidConcEscape(linha)}
`; }) .join(""); } // Posição do último "apertar" do mouse, para saber se um clique foi na // verdade um arraste (seleção de texto para copiar). Captura, para valer // mesmo que algum handler pare a propagação. let pidConcMouseDown = null; document.addEventListener("mousedown", (evento) => { pidConcMouseDown = { x: evento.clientX, y: evento.clientY }; }, true); /** O clique terminou um arraste (mouse andou mais de 4 px desde o mousedown)? */ function pidConcFoiArraste(evento) { if (!pidConcMouseDown) return false; return Math.hypot(evento.clientX - pidConcMouseDown.x, evento.clientY - pidConcMouseDown.y) > 4; } function pidConcEscondeTooltip(evento) { const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]"); if (!alvo || !pidConcTooltipEl || alvo.contains(evento.relatedTarget)) return; pidConcTooltipEl.hidden = true; } document.addEventListener("mouseover", pidConcMostraTooltip); document.addEventListener("mouseout", pidConcEscondeTooltip); window.addEventListener("scroll", () => { if (pidConcTooltipEl) pidConcTooltipEl.hidden = true; }, true); function pidConcMoeda(valor) { return PID_CONC_MOEDA.format(Number(valor) || 0); } const PID_CONC_ICONE_INFO = ''; // ---- Histórico de vinculações da conta (botão "i" do razão) ---- // Pedido do usuário: saber quem vinculou/desvinculou o quê e com qual seq. // Modal criado sob demanda e injetado no body (padrão de ajuda-aplicacao.js). let pidConcHistoricoModal = null; function pidConcHistoricoLancamentoHtml(lanc) { const seq = lanc.eh_saldo_anterior ? "Saldo anterior" : `Seq. ${pidConcEscape(lanc.sequencia || "—")}`; return `
  • ${seq} ${pidConcData(lanc.data)} ${lanc.natureza === "C" ? "Crédito" : "Débito"} ${lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(lanc.historico || "")} ${lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""} ${pidConcMoeda(lanc.valor)} ${lanc.acrescentado ? 'acrescentado' : ""}
  • `; } function pidConcHistoricoRegistroHtml(registro) { const origem = registro.vinculo_automatico ? "automático" : "manual"; return `
  • ${pidConcEscape(registro.acao_rotulo)} ${pidConcEscape(registro.tipo_rotulo)} · ${origem} ${pidConcEscape(registro.usuario_nome || "—")} · ${pidConcDataHora(registro.criado_em)}
  • `; } /** Abre o modal de informação (um só, reaproveitado pelo histórico de * vinculações e pelo detalhe de um vínculo) e devolve o corpo a preencher. */ function pidConcAbreModalInfo(titulo, subtitulo) { if (!pidConcHistoricoModal) { pidConcHistoricoModal = document.createElement("div"); pidConcHistoricoModal.className = "modal-overlay"; pidConcHistoricoModal.hidden = true; pidConcHistoricoModal.innerHTML = ` `; document.body.appendChild(pidConcHistoricoModal); const fechar = () => { pidConcHistoricoModal.hidden = true; }; pidConcHistoricoModal.querySelector("[data-historico-fechar]").addEventListener("click", fechar); pidConcHistoricoModal.addEventListener("click", (evento) => { if (evento.target === pidConcHistoricoModal) fechar(); }); } pidConcHistoricoModal.querySelector("[data-historico-titulo]").textContent = titulo; pidConcHistoricoModal.querySelector("[data-historico-subtitulo]").textContent = subtitulo; pidConcHistoricoModal.hidden = false; return pidConcHistoricoModal.querySelector("[data-historico-corpo]"); } // ---- Detalhe de um vínculo (botão "i" na linha vinculada) ---- // Pedido do usuário: ver o que o vínculo automático considerou sem caçar as // outras linhas no razão (podem estar longe). Tudo já vem no payload da conta. const PID_CONC_CRITERIO_VINCULO = { exato: "Um crédito e um débito de mesmo valor. Havendo mais de um candidato, fica o de data mais próxima (e, no empate, o pagamento feito depois do título).", combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima). Critério usado só em importações até 29/09/2026: hoje esse caso fica pendente, como sugestão.", sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível. Critério usado só em importações até 29/09/2026: hoje esse caso fica pendente, como sugestão.", saldo_zerado: "Trecho do razão em que o saldo da conta voltou a zero: o que sobrou nele se compensa por definição, então vira um único vínculo.", tolerancia: "Débito e crédito (ou dois itens) com uma diferença pequena, dentro da tolerância da conciliação (valor fixo ou percentual do título, o maior). Possível juros/desconto. Critério usado só em importações até 25/09/2026: hoje esse caso fica pendente, como sugestão.", manual: "Vinculado manualmente.", parcial: "Pagamento parcial vinculado manualmente: a diferença continua em aberto.", }; function pidConcAbreDetalheVinculo(conta, vinculoId, numero) { const vinculo = conta.vinculos.find((v) => v.id === vinculoId); if (!vinculo) return; const lancs = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId); const creditos = lancs.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); const debitos = lancs.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0); const diferenca = Math.round((creditos - debitos) * 100) / 100; const automatico = vinculo.tipo !== "manual" && vinculo.tipo !== "parcial"; let criterio = PID_CONC_CRITERIO_VINCULO[vinculo.tipo] || ""; if (!automatico && vinculo.criado_por_nome) criterio = `${criterio} Por ${vinculo.criado_por_nome}.`; // Pagamento parcial com saldo: o saldo a pagar vai em destaque (pedido do // usuário), no lugar da frase de diferença. const saldoParcial = vinculo.tipo === "parcial" && Number(vinculo.diferenca) > 0 ? Number(vinculo.diferenca) : 0; const resultado = saldoParcial ? `Saldo a pagar ${pidConcMoeda(saldoParcial)}` : `${ diferenca === 0 ? "Fecha sem diferença." : diferenca > 0 ? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos.` : `Débitos ${pidConcMoeda(-diferenca)} maiores que os créditos.` }`; const corpo = pidConcAbreModalInfo(`Vínculo #${numero || "?"}`, `${conta.conta_numero} - ${conta.fornecedor}`); corpo.innerHTML = `
    ${pidConcEscape(vinculo.tipo_rotulo)} ${automatico ? "automático" : "manual"}

    ${pidConcEscape(criterio)}

    Créditos ${pidConcMoeda(creditos)} Débitos ${pidConcMoeda(debitos)} ${resultado}
    `; } async function pidConcAbreHistoricoVinculos(conta) { const corpo = pidConcAbreModalInfo("Histórico de vinculações", `${conta.conta_numero} - ${conta.fornecedor}`); corpo.innerHTML = '

    Carregando…

    '; try { const registros = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/historico-vinculos/`); corpo.innerHTML = registros.length ? `` : '

    Nenhuma vinculação ou desvinculação manual registrada nesta conta.

    '; } catch (erro) { corpo.innerHTML = ``; } } /** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */ function pidConcSaldo(valor) { const numero = Number(valor) || 0; if (numero === 0) return pidConcMoeda(0); return `${pidConcMoeda(Math.abs(numero))} ${numero > 0 ? "C" : "D"}`; } function pidConcData(iso) { if (!iso) return "—"; const [ano, mes, dia] = iso.slice(0, 10).split("-"); return `${dia}/${mes}/${ano}`; } function pidConcIsoLocal(data) { const mes = String(data.getMonth() + 1).padStart(2, "0"); const dia = String(data.getDate()).padStart(2, "0"); return `${data.getFullYear()}-${mes}-${dia}`; } /** Sugestão do campo "Razão até": último dia do mês anterior (o normal é * exportar o razão depois do fechamento do mês), nunca antes de `minimo`. */ function pidConcRazaoAtePadrao(minimo) { const hoje = new Date(); const padrao = pidConcIsoLocal(new Date(hoje.getFullYear(), hoje.getMonth(), 0)); return minimo && minimo > padrao ? minimo : padrao; } /** Campo "Razão até" (`.conc-data-field`): texto digitável em dd/mm/aaaa * sobre um `` escondido, que guarda o valor ISO e segue * sendo o lido pelo resto da tela (`.value`/`.min`/`.max`). O campo nativo * aceita digitar 31/09 mas só devolve `value` vazio, sem dizer o que foi * digitado; aqui, dia além do fim do mês vira o último dia (pedido do * usuário: 31/09/2026 → 30/09/2026), sem aviso (também pedido dele; o * aviso abaixo do campo fica só para erro). O botão abre o calendário * nativo. Devolve `definir(iso)` (preencher por código) e `validar()` (erro * do texto digitado, "" se válido ou vazio). */ function pidConcCampoData(dataInput) { const campo = dataInput.closest(".conc-data-field"); const textoInput = campo.querySelector("input[type='text']"); const avisoEl = campo.parentElement.querySelector(".conc-data-field__aviso"); const dois = (n) => String(n).padStart(2, "0"); function avisar(mensagem, erro) { avisoEl.textContent = mensagem; avisoEl.classList.toggle("is-erro", !!erro); avisoEl.hidden = !mensagem; } function definir(iso) { dataInput.value = iso || ""; textoInput.value = dataInput.value ? pidConcData(dataInput.value) : ""; avisar(""); } /** Lê o texto; com `completo`, texto parcial conta como erro. */ function interpretar(completo) { const digitos = textoInput.value.replace(/\D/g, ""); dataInput.value = ""; if (!digitos) { avisar(""); return ""; } if (digitos.length < 8) { if (!completo) { avisar(""); return ""; } const erro = "Data incompleta: use dd/mm/aaaa."; avisar(erro, true); return erro; } const dia = Number(digitos.slice(0, 2)); const mes = Number(digitos.slice(2, 4)); const ano = Number(digitos.slice(4, 8)); if (dia < 1 || mes < 1 || mes > 12 || ano < 1900) { const erro = "Data inválida: confira o dia e o mês (dd/mm/aaaa)."; avisar(erro, true); return erro; } const ultimoDia = new Date(ano, mes, 0).getDate(); definir(`${ano}-${dois(mes)}-${dois(Math.min(dia, ultimoDia))}`); return ""; } textoInput.addEventListener("input", (evento) => { // Máscara dd/mm/aaaa; apagando, não recoloca a barra. if (!evento.inputType || !evento.inputType.startsWith("delete")) { const d = textoInput.value.replace(/\D/g, "").slice(0, 8); textoInput.value = [d.slice(0, 2), d.slice(2, 4), d.slice(4)].filter(Boolean).join("/"); } interpretar(false); }); textoInput.addEventListener("blur", () => interpretar(true)); dataInput.addEventListener("change", () => definir(dataInput.value)); campo.querySelector(".conc-data-field__btn").addEventListener("click", () => { try { dataInput.showPicker(); } catch (erro) { textoInput.focus(); // navegador sem showPicker(): fica a digitação } }); return { definir, validar: () => interpretar(true), focar: () => textoInput.focus() }; } /** Lê `#conciliacao-[?filtros]` da URL: `{ id, params }` ou `null`. */ function pidConcLeHash() { const partes = window.location.hash.match(/^#conciliacao-(\d+)(?:\?(.*))?$/); if (!partes) return null; return { id: Number(partes[1]), params: new URLSearchParams(partes[2] || "") }; } /** Busca sem redesenhar a lista a cada tecla: roda `funcao` só depois de * `ms` sem digitação (a lista de fornecedores tem centenas de linhas). */ function pidConcAtrasado(funcao, ms = 150) { let timer = null; return () => { clearTimeout(timer); timer = setTimeout(funcao, ms); }; } /** Barra de progresso de "Nova Conciliação"/"Atualizar razão" (mesmo padrão * de `pidDcIniciarProgressoSimulado` do Relatório Contábil): o processamento * é uma requisição síncrona, sem percentual real. Sobe rápido e desacelera * perto de 90%; só `completar()`, quando a resposta volta, chega a 100%. */ function pidConcIniciarProgresso(progressEl) { const fillEl = progressEl.querySelector(".conc-progress__fill"); let pct = 0; fillEl.style.width = "0%"; progressEl.hidden = false; const intervalId = setInterval(() => { pct += (90 - pct) * 0.1; fillEl.style.width = `${pct}%`; }, 200); return { parar() { clearInterval(intervalId); progressEl.hidden = true; }, async completar() { clearInterval(intervalId); fillEl.style.width = "100%"; await new Promise((resolve) => setTimeout(resolve, 300)); progressEl.hidden = true; }, }; } function pidConcDataHora(iso) { if (!iso) return "—"; const data = new Date(iso); if (Number.isNaN(data.getTime())) return "—"; return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }); } function pidConcCompara(a, b) { if (typeof a === "number" && typeof b === "number") return a - b; return String(a ?? "").localeCompare(String(b ?? ""), "pt-BR", { sensitivity: "base", numeric: true }); } /** Valor em aberto de um item: sempre o real (só vínculo muda valor). */ function pidConcValorAbertoHtml(situacao) { if (!situacao || !Number(situacao.valor_em_aberto)) return "—"; return pidConcMoeda(situacao.valor_em_aberto); } /** Sugestão escrita em várias linhas (uma por título, para o balão) numa * linha só, para a célula cortada com reticências: "; " entre itens, espaço * depois de linha terminada em ":" ou ".". Mesma regra de * `texto_em_linha()` em `alertas.py` (XLSX). */ function pidConcTextoEmLinha(texto) { const partes = String(texto || "").split("\n").map((p) => p.trim()).filter(Boolean); return partes.reduce((acc, parte) => (acc ? acc + (/[:.]$/.test(acc) ? " " : "; ") + parte : parte), ""); } /** Célula de situação: o fato primeiro; a dedução da ferramenta, se houver, * numa linha à parte marcada como sugestão (nunca misturada ao fato). O * balão mostra a sugestão com as quebras de linha (`white-space: pre-line` * em `.conc-tooltip`). */ function pidConcSituacaoHtml(situacao) { if (!situacao) return ''; const sugestao = situacao.sugestao ? `Sugestão${pidConcEscape(pidConcTextoEmLinha(situacao.sugestao))}` : ""; return `${pidConcEscape(situacao.descricao)}${sugestao}`; } function pidConcTextoValidacao(conta) { if (!conta.validada) return ""; if (conta.validada_automatica) return `Validada automaticamente (conta conciliada) em ${pidConcDataHora(conta.validada_em)}`; return `Validada por ${conta.validada_por_nome || "—"} em ${pidConcDataHora(conta.validada_em)}`; } function pidConcStatusHtml(status) { const info = PID_CONC_STATUS[status] || PID_CONC_STATUS.conciliada; return `${info.rotulo}`; } document.addEventListener("DOMContentLoaded", async () => { const contentEl = document.getElementById("conc-content"); if (!contentEl) return; const me = await pidGetMe(); if (!me) return; const utilitarios = (me.permissoes_efetivas && me.permissoes_efetivas["utilitarios"]) || {}; const podeUsar = !!(utilitarios.apps && utilitarios.apps["conciliacao-fornecedores"]); document.getElementById("conc-no-access").hidden = podeUsar; contentEl.hidden = !podeUsar; if (!podeUsar) return; const listView = document.getElementById("conc-list-view"); const detailView = document.getElementById("conc-detail-view"); // ====================================================================== // Histórico + nova conciliação // ====================================================================== const formEl = document.getElementById("conc-form"); const novaBtn = document.getElementById("conc-nova-btn"); const codigoInput = document.getElementById("conc-form-codigo"); const empresaEl = document.getElementById("conc-form-empresa"); const arquivoInput = document.getElementById("conc-form-arquivo"); const arquivoNomeEl = document.getElementById("conc-form-arquivo-nome"); const periodoFimInput = document.getElementById("conc-form-periodo-fim"); const periodoFimCampo = pidConcCampoData(periodoFimInput); const formErroEl = document.getElementById("conc-form-erro"); const processarBtn = document.getElementById("conc-form-processar-btn"); const listaBody = document.getElementById("conc-lista-body"); const listaVazia = document.getElementById("conc-lista-vazia"); const listaBusca = document.getElementById("conc-lista-busca"); let historico = []; let nomeEmpresaResolvido = null; // null = não consultado/não encontrado let timerNomeEmpresa = null; let consultaNomeAtual = 0; function limparFormulario() { codigoInput.value = ""; empresaEl.textContent = ""; empresaEl.className = "conc-form__empresa"; arquivoInput.value = ""; arquivoNomeEl.textContent = "Nenhum arquivo selecionado"; periodoFimCampo.definir(""); // em branco: o contador informa (pedido do usuário) periodoFimInput.max = pidConcIsoLocal(new Date()); formErroEl.textContent = ""; nomeEmpresaResolvido = null; } novaBtn.addEventListener("click", () => { const abrir = formEl.hidden; formEl.hidden = !abrir; if (abrir) { limparFormulario(); codigoInput.focus(); } }); document.getElementById("conc-form-cancelar-btn").addEventListener("click", () => { formEl.hidden = true; }); arquivoInput.addEventListener("change", () => { const arquivo = arquivoInput.files[0]; arquivoNomeEl.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado"; // O nome do arquivo costuma começar pelo código da empresa ("2017 - ..."): // sugere, sem sobrescrever o que já foi digitado. const prefixo = arquivo && arquivo.name.match(/^(\d+)\s*[-_ ]/); if (prefixo && !codigoInput.value.trim()) { codigoInput.value = prefixo[1]; buscarNomeEmpresa(); } }); async function buscarNomeEmpresa() { const codigo = codigoInput.value.trim(); nomeEmpresaResolvido = null; if (!codigo) { empresaEl.textContent = ""; empresaEl.className = "conc-form__empresa"; return; } if (!/^\d+$/.test(codigo)) { empresaEl.textContent = "Informe apenas números."; empresaEl.className = "conc-form__empresa is-erro"; return; } const consulta = ++consultaNomeAtual; empresaEl.textContent = "Buscando no Questor…"; empresaEl.className = "conc-form__empresa is-carregando"; try { const dados = await pidApiRequest(`/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=${encodeURIComponent(codigo)}`); if (consulta !== consultaNomeAtual) return; nomeEmpresaResolvido = dados.nome_empresa; empresaEl.textContent = dados.nome_empresa || "Empresa não encontrada no Questor."; empresaEl.className = `conc-form__empresa${dados.nome_empresa ? "" : " is-erro"}`; } catch (erro) { if (consulta !== consultaNomeAtual) return; empresaEl.textContent = erro.message; empresaEl.className = "conc-form__empresa is-erro"; } } codigoInput.addEventListener("input", () => { clearTimeout(timerNomeEmpresa); timerNomeEmpresa = setTimeout(buscarNomeEmpresa, 450); }); processarBtn.addEventListener("click", async () => { formErroEl.textContent = ""; const codigo = codigoInput.value.trim(); const arquivo = arquivoInput.files[0]; if (!codigo) { formErroEl.textContent = "Informe o código da empresa."; codigoInput.focus(); return; } if (!arquivo) { formErroEl.textContent = "Selecione o arquivo do razão."; return; } const erroPeriodoFim = periodoFimCampo.validar(); if (erroPeriodoFim || !periodoFimInput.value) { formErroEl.textContent = erroPeriodoFim ? `"Razão até": ${erroPeriodoFim}` : "Informe até quando o razão foi exportado (\"Razão até\")."; periodoFimCampo.focar(); return; } clearTimeout(timerNomeEmpresa); // Uma conciliação por empresa: o razão novo vai para a existente. const codigoCanonico = codigo.replace(/^0+(?=\d)/, ""); // mesmo critério de normalizar_codigo_empresa() const existente = historico.find((item) => String(item.codigo_empresa) === codigoCanonico); if (existente) { const atualizar = await pidConfirm( `A empresa ${codigo} já tem uma conciliação (${existente.periodo_inicio ? `${pidConcData(existente.periodo_inicio)} a ${pidConcData(existente.periodo_fim)}` : "sem lançamentos"}). O razão novo substitui o dela. Atualizar essa conciliação com este arquivo?`, { titulo: "Empresa já conciliada", textoConfirmar: "Atualizar a existente" } ); if (!atualizar) return; formEl.hidden = true; abrirAtualizar(existente, arquivo, periodoFimInput.value); return; } if (nomeEmpresaResolvido === null) await buscarNomeEmpresa(); if (!nomeEmpresaResolvido) { const seguir = await pidConfirm( `A empresa ${codigo} não foi encontrada no Questor (ou o Questor está inacessível agora). Processar assim mesmo?`, { titulo: "Empresa não encontrada", textoConfirmar: "Processar" } ); if (!seguir) return; } const dados = new FormData(); dados.append("codigo_empresa", codigo); dados.append("arquivo", arquivo); dados.append("periodo_fim", periodoFimInput.value); const cancelarBtn = document.getElementById("conc-form-cancelar-btn"); processarBtn.disabled = true; cancelarBtn.disabled = true; processarBtn.textContent = "Processando…"; const progresso = pidConcIniciarProgresso(document.getElementById("conc-form-progress")); try { const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados }); await progresso.completar(); formEl.hidden = true; await carregarHistorico(); abrirDetalhe(criada.id, { empilhar: true }); } catch (erro) { progresso.parar(); formErroEl.textContent = erro.message; } finally { processarBtn.disabled = false; cancelarBtn.disabled = false; processarBtn.textContent = "Processar"; } }); function renderHistorico() { const termo = listaBusca.value.trim().toLowerCase(); const itens = historico.filter( (item) => !termo || `${item.codigo_empresa} ${item.nome_empresa}`.toLowerCase().includes(termo) ); listaBody.innerHTML = ""; listaVazia.hidden = itens.length > 0; listaVazia.textContent = historico.length ? "Nenhuma conciliação para esta busca." : "Nenhuma conciliação realizada ainda."; itens.forEach((item) => { const resumo = item.resumo || {}; const porStatus = resumo.por_status || {}; // Sem atualização do razão, a última atualização é a própria criação. const ultima = item.ultima_atualizacao; const ultimaPor = ultima ? ultima.usuario_nome : item.criado_por_nome; const ultimaEm = ultima ? ultima.criado_em : item.criado_em; const periodo = item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "—"; const tr = document.createElement("tr"); tr.className = "conc-row"; tr.innerHTML = ` ${pidConcEscape(item.codigo_empresa)} - ${pidConcEscape(item.nome_empresa || "Empresa não encontrada no Questor")} ${periodo} ${resumo.total_contas ?? "—"} ${porStatus.divergente ? `${porStatus.divergente}` : 0} ${pidConcMoeda(resumo.total_em_aberto)} ${pidConcEscape(ultimaPor || "—")}${pidConcDataHora(ultimaEm)} `; tr.addEventListener("click", (evento) => { if (evento.target.closest("[data-atualizar]")) return; abrirDetalhe(item.id, { empilhar: true }); }); tr.querySelector("[data-atualizar]").addEventListener("click", () => abrirAtualizar(item)); listaBody.appendChild(tr); }); } listaBusca.addEventListener("input", pidConcAtrasado(renderHistorico)); async function carregarHistorico() { try { historico = await pidApiRequest("/conciliacoes-fornecedores/"); } catch (erro) { historico = []; listaVazia.hidden = false; listaVazia.textContent = erro.message; return; } renderHistorico(); } // ====================================================================== // Atualizar o razão (arquivo novo substitui o da conciliação) // ====================================================================== const atualizarModal = document.getElementById("conc-atualizar-modal"); const atualizarArquivoInput = document.getElementById("conc-atualizar-arquivo"); const atualizarArquivoNome = document.getElementById("conc-atualizar-arquivo-nome"); const atualizarPeriodoFim = document.getElementById("conc-atualizar-periodo-fim"); const atualizarPeriodoFimCampo = pidConcCampoData(atualizarPeriodoFim); const atualizarErro = document.getElementById("conc-atualizar-erro"); const atualizarBtn = document.getElementById("conc-atualizar-processar-btn"); let atualizarAlvo = null; function periodoTexto(item) { return item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "sem lançamentos"; } /** `arquivoInicial`/`periodoFimInicial`: arquivo e "Razão até" já escolhidos * em "Nova Conciliação" para uma empresa que já tinha conciliação. */ function abrirAtualizar(item, arquivoInicial, periodoFimInicial) { atualizarAlvo = item; document.getElementById("conc-atualizar-empresa").textContent = `${item.codigo_empresa} - ${item.nome_empresa || "Empresa não encontrada no Questor"}`; document.getElementById("conc-atualizar-periodo").innerHTML = `Período atual do razão: ${periodoTexto(item)}`; atualizarArquivoInput.value = ""; if (arquivoInicial) { const transferencia = new DataTransfer(); transferencia.items.add(arquivoInicial); atualizarArquivoInput.files = transferencia.files; } atualizarArquivoNome.textContent = arquivoInicial ? arquivoInicial.name : "Nenhum arquivo selecionado"; // O razão novo não pode terminar antes do atual. atualizarPeriodoFim.min = item.periodo_fim || ""; atualizarPeriodoFim.max = pidConcIsoLocal(new Date()); atualizarPeriodoFimCampo.definir( periodoFimInicial && (!item.periodo_fim || periodoFimInicial >= item.periodo_fim) ? periodoFimInicial : pidConcRazaoAtePadrao(item.periodo_fim) ); atualizarErro.textContent = ""; atualizarModal.hidden = false; } function fecharAtualizar() { if (atualizarBtn.disabled) return; // processando atualizarModal.hidden = true; atualizarAlvo = null; } atualizarArquivoInput.addEventListener("change", () => { const arquivo = atualizarArquivoInput.files[0]; atualizarArquivoNome.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado"; }); document.getElementById("conc-atualizar-cancelar-btn").addEventListener("click", fecharAtualizar); atualizarModal.addEventListener("click", (evento) => { if (evento.target === atualizarModal) fecharAtualizar(); }); document.getElementById("conc-det-atualizar-btn").addEventListener("click", () => { if (detalhe) abrirAtualizar(detalhe); }); function resumoAtualizacaoTexto(r) { const n = (chave) => r[chave] || 0; const linhas = [ n("acrescimo") ? "Período novo acrescentado: saldos iniciais conferidos com a conciliação, e o que já existia foi mantido." : "Razão substituído pelo arquivo novo.", `Período do razão: ${periodoTexto(atualizarAlvo)} → ${r.periodo_novo}.`, n("lancamentos_novos") || n("lancamentos_removidos") ? `Lançamentos: ${n("lancamentos_mantidos")} mantidos, ${n("lancamentos_novos")} novos, ${n("lancamentos_removidos")} fora do arquivo novo.` : `Lançamentos: nenhum lançamento novo no período (${n("lancamentos_mantidos")} mantidos).`, `Vínculos: ${n("vinculos_mantidos")} mantidos, ${n("vinculos_desfeitos")} desfeitos (${n("vinculos_manuais_desfeitos")} manuais), ${n("vinculos_automaticos_novos")} automáticos novos.`, ]; if (n("contas_novas") || n("contas_removidas")) { linhas.push(`Fornecedores: ${n("contas_novas")} novos, ${n("contas_removidas")} fora do arquivo novo.`); } if (n("contas_sem_movimento")) { linhas.push(`Fornecedores sem movimento no período novo (mantidos como estavam): ${n("contas_sem_movimento")}.`); } if (n("validacoes_retiradas") || n("revisoes_vencidos_retiradas")) { linhas.push( `A validar de novo: ${n("validacoes_retiradas")} conta(s) e ${n("revisoes_vencidos_retiradas")} revisão(ões) de títulos antigos.` ); } return linhas.join("\n"); } atualizarBtn.addEventListener("click", async () => { atualizarErro.textContent = ""; const arquivo = atualizarArquivoInput.files[0]; if (!arquivo) { atualizarErro.textContent = "Selecione o arquivo do razão atualizado."; return; } const erroPeriodoFim = atualizarPeriodoFimCampo.validar(); if (erroPeriodoFim || !atualizarPeriodoFim.value) { atualizarErro.textContent = erroPeriodoFim ? `"Razão até": ${erroPeriodoFim}` : "Informe até quando o razão foi exportado (\"Razão até\")."; atualizarPeriodoFimCampo.focar(); return; } const alvo = atualizarAlvo; const dados = new FormData(); dados.append("arquivo", arquivo); dados.append("periodo_fim", atualizarPeriodoFim.value); const cancelarBtn = document.getElementById("conc-atualizar-cancelar-btn"); atualizarBtn.disabled = true; cancelarBtn.disabled = true; atualizarBtn.textContent = "Atualizando…"; const progresso = pidConcIniciarProgresso(document.getElementById("conc-atualizar-progress")); let resposta; try { resposta = await pidApiRequest(`/conciliacoes-fornecedores/${alvo.id}/atualizar/`, { method: "POST", body: dados }); await progresso.completar(); } catch (erro) { progresso.parar(); atualizarErro.textContent = erro.message; return; } finally { atualizarBtn.disabled = false; cancelarBtn.disabled = false; atualizarBtn.textContent = "Atualizar"; } fecharAtualizar(); atualizarAlvo = alvo; // para o texto do período anterior const texto = resumoAtualizacaoTexto({ ...resposta.atualizacao, periodo_novo: periodoTexto(resposta.conciliacao) }); atualizarAlvo = null; if (!detailView.hidden && detalhe && detalhe.id === alvo.id) { const atual = pidConcLeHash(); await abrirDetalhe(alvo.id, { params: atual && atual.params }); } else { await carregarHistorico(); } pidAlert(texto, { titulo: "Razão atualizado" }); }); // ====================================================================== // Detalhe // ====================================================================== const cardsEl = document.getElementById("conc-cards"); const contasBody = document.getElementById("conc-contas-body"); const contasVazia = document.getElementById("conc-contas-vazia"); const contasBusca = document.getElementById("conc-contas-busca"); const filtroGrupoBtns = Array.from(document.querySelectorAll("[data-conc-grupo]")); const contasTable = document.getElementById("conc-contas-table"); // Filtro por ajuste (parcelamento/juros/desconto): só nos chips de validadas. let filtroAjuste = ""; const filtroAlertaEl = document.getElementById("conc-filtro-alerta"); const sortHeaders = Array.from(document.querySelectorAll("#conc-contas-table th[data-sort]")); let detalhe = null; const GRUPO_PADRAO = "pendentes"; let filtroGrupo = GRUPO_PADRAO; let filtroAlerta = ""; // Ordem padrão de cada chip: nos pendentes, por situação, críticos // primeiro; nos de validadas, da validação mais recente para a mais // antiga (pedidos do usuário). function sortPadrao() { return filtroGrupo === "pendentes" ? { key: "status", dir: 1 } : { key: "validada_em", dir: -1 }; } let sortKey = "status"; let sortDir = 1; function restauraSortPadrao() { ({ key: sortKey, dir: sortDir } = sortPadrao()); } const abertas = new Set(); // estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados const estadoConta = new Map(); // Observações digitadas e ainda não salvas, por conta (pedido do usuário: // o texto não pode sumir quando a conta é redesenhada). Sobrevivem ao // recolher/expandir e à troca de filtro; ao sair da conciliação com // rascunho pendente, a tela pergunta antes. const rascunhos = new Map(); // observação da conciliação const rascunhosVenc = new Map(); // observação dos títulos antigos function estadoDe(contaId) { if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false }); return estadoConta.get(contaId); } function temRascunhoPendente() { if (!detalhe) return false; return detalhe.contas.some( (c) => (rascunhos.has(c.id) && rascunhos.get(c.id) !== (c.observacao || "")) || (rascunhosVenc.has(c.id) && rascunhosVenc.get(c.id) !== (c.vencidos_observacao || "")) ); } /** Pergunta antes de descartar observações não salvas; `true` = pode sair. */ async function podeSairDoDetalhe() { if (!temRascunhoPendente()) return true; const sair = await pidConfirm( "Há observações digitadas e não salvas nesta conciliação. Sair sem salvar as alterações?", { titulo: "Sair sem salvar", textoConfirmar: "Sair sem salvar", perigoso: true } ); if (sair) { rascunhos.clear(); rascunhosVenc.clear(); } return sair; } window.addEventListener("beforeunload", (evento) => { if (temRascunhoPendente()) evento.preventDefault(); }); // ---- Estado na URL ---- // `#conciliacao-` seguido dos filtros, da aba e da ordenação // (`?aba=vencidos&grupo=validados&ordem=-saldo_final`), para recarregar // a página voltar ao mesmo ponto. Abrir uma conciliação empilha uma // entrada no histórico do navegador, então o Voltar dele volta à lista; // trocar filtro só substitui a entrada atual. function hashDetalhe() { const params = new URLSearchParams(); if (abaAtual !== "conciliacao") params.set("aba", abaAtual); if (filtroGrupo !== GRUPO_PADRAO) params.set("grupo", filtroGrupo); if (filtroAjuste) params.set("ajuste", filtroAjuste); if (filtroAlerta) params.set("alerta", filtroAlerta); const padrao = sortPadrao(); if (sortKey !== padrao.key || sortDir !== padrao.dir) params.set("ordem", `${sortDir === -1 ? "-" : ""}${sortKey}`); if (vencFiltro !== "pendentes") params.set("vfiltro", vencFiltro || "todos"); if (vencSortKey !== "total_vencido" || vencSortDir !== -1) params.set("vordem", `${vencSortDir === -1 ? "-" : ""}${vencSortKey}`); const consulta = params.toString(); return `#conciliacao-${detalhe.id}${consulta ? `?${consulta}` : ""}`; } function atualizarUrl() { if (!detalhe || detailView.hidden) return; const hash = hashDetalhe(); if (window.location.hash !== hash) history.replaceState(history.state, "", hash); } /** Aplica os parâmetros lidos da URL (valores desconhecidos são ignorados). */ function aplicarParametros(params) { if (!params) return; const grupo = params.get("grupo") || ""; if (PID_CONC_GRUPOS[grupo]) filtroGrupo = grupo; const ajuste = params.get("ajuste") || ""; if (PID_CONC_AJUSTES[ajuste] && filtroGrupo !== "pendentes") filtroAjuste = ajuste; restauraSortPadrao(); const alerta = params.get("alerta") || ""; if (PID_CONC_ROTULO_ALERTA[alerta]) filtroAlerta = alerta; const ordem = params.get("ordem") || ""; if (sortValor[ordem.replace(/^-/, "")]) { sortKey = ordem.replace(/^-/, ""); sortDir = ordem.startsWith("-") ? -1 : 1; } const vfiltro = params.get("vfiltro"); if (vfiltro === "validados" || vfiltro === "todos") vencFiltro = vfiltro === "todos" ? "" : vfiltro; const vordem = params.get("vordem") || ""; if (vencSortValor[vordem.replace(/^-/, "")]) { vencSortKey = vordem.replace(/^-/, ""); vencSortDir = vordem.startsWith("-") ? -1 : 1; } } document.getElementById("conc-voltar-btn").addEventListener("click", async () => { if (!(await podeSairDoDetalhe())) return; // Entrou pela lista: volta a entrada do histórico (o `popstate` mostra a // lista). Entrou direto pela URL: não há para onde voltar, então troca. if (history.state && history.state.concDetalhe) { history.back(); } else { history.replaceState(null, "", window.location.pathname); mostrarLista(); } }); window.addEventListener("popstate", async () => { const alvo = pidConcLeHash(); if (alvo && detalhe && alvo.id === detalhe.id && !detailView.hidden) return; if (!detailView.hidden && !(await podeSairDoDetalhe())) { // cancelou: devolve a entrada da conciliação aberta history.pushState({ concDetalhe: true }, "", hashDetalhe()); return; } if (alvo) abrirDetalhe(alvo.id, { params: alvo.params }); else mostrarLista(); }); function mostrarLista() { detalhe = null; rascunhos.clear(); rascunhosVenc.clear(); detailView.hidden = true; listView.hidden = false; carregarHistorico(); } /** `empilhar`: abrir a partir da lista (cria entrada no histórico do * navegador). `params`: filtros/aba/ordem lidos da URL. Reabrir a mesma * conciliação (depois de "Atualizar razão") mantém os rascunhos. */ async function abrirDetalhe(id, { empilhar = false, params = null } = {}) { const mesma = detalhe && detalhe.id === id; listView.hidden = true; detailView.hidden = false; cardsEl.innerHTML = ""; contasBody.innerHTML = 'Carregando…'; if (empilhar) history.pushState({ concDetalhe: true }, "", `#conciliacao-${id}`); else if (!pidConcLeHash() || pidConcLeHash().id !== id) history.replaceState(history.state, "", `#conciliacao-${id}`); if (!mesma) { rascunhos.clear(); rascunhosVenc.clear(); } abertas.clear(); estadoConta.clear(); vencAbertas.clear(); vencFiltro = "pendentes"; vencSortKey = "total_vencido"; vencSortDir = -1; vencBusca.value = ""; detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar mostrarAba("conciliacao"); filtroGrupo = GRUPO_PADRAO; filtroAjuste = ""; filtroAlerta = ""; contasBusca.value = ""; restauraSortPadrao(); aplicarParametros(params); atualizarSortHeaders(); try { detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`); } catch (erro) { contasBody.innerHTML = `${pidConcEscape(erro.message)}`; return; } renderCabecalho(); const abaUrl = params && params.get("aba"); mostrarAba(abaUrl === "vencidos" || abaUrl === "relatorio" ? abaUrl : "conciliacao"); } function renderCabecalho() { document.getElementById("conc-det-codigo").textContent = `EMPRESA ${detalhe.codigo_empresa}`; document.getElementById("conc-det-nome").textContent = detalhe.nome_empresa || "Empresa não encontrada no Questor"; const periodo = detalhe.periodo_inicio ? `${pidConcData(detalhe.periodo_inicio)} a ${pidConcData(detalhe.periodo_fim)}` : "sem lançamentos"; // Sem atualização do razão, a última atualização é a própria criação // (mesma regra da coluna da lista). const ultima = detalhe.ultima_atualizacao; const ultimaPor = ultima ? ultima.usuario_nome : detalhe.criado_por_nome; const ultimaEm = ultima ? ultima.criado_em : detalhe.criado_em; document.getElementById("conc-det-meta").innerHTML = ` Período do razão ${periodo} Arquivo ${pidConcEscape(detalhe.nome_arquivo || "—")} Última atualização ${pidConcEscape(ultimaPor || "—")} em ${pidConcDataHora(ultimaEm)}`; document.getElementById("conc-exportar-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/exportar-xlsx/`; } function contasComAlerta(tipo) { return detalhe.contas.filter((c) => c.alertas.some((a) => a.tipo === tipo)); } /** Faixa de indicadores (3 valores + progresso da validação) e etiquetas * dos apontamentos menores. Os números por situação ficam só nos chips, * que já filtram (não duplicar em card). */ function renderCards() { const contas = detalhe.contas; const soma = (lista, campo) => lista.reduce((total, c) => total + (Number(c[campo]) || 0), 0); const vencidas = contasVencidas(); const vencidasValidadas = vencidas.filter((c) => c.vencidos_validados).length; const aVincular = contas.filter((c) => Number(c.total_nao_vinculado) > 0); const emAberto = contas.filter((c) => Number(c.total_em_aberto) > 0); const criticasPendentes = contas.filter((c) => c.status === "divergente" && !c.validada).length; const validados = contas.filter((c) => c.validada).length; const pct = contas.length ? Math.round((validados / contas.length) * 100) : 0; const kpi = (rotulo, valor, detalheTxt, classe, alerta) => { const tag = alerta ? "button" : "div"; const ativo = alerta && filtroAlerta === alerta ? " is-active" : ""; const atributos = alerta ? ` type="button" data-conc-alerta="${alerta}"` : ""; return `<${tag} class="conc-kpi ${classe}${ativo}"${atributos}> ${rotulo} ${valor} ${detalheTxt} `; }; const apontamentos = [ ["diferenca_vinculo", "com diferença de juros/desconto", "info"], ["conta_transitoria", "conta transitória com saldo", "danger"], ] .map(([tipo, texto, classe]) => [tipo, contasComAlerta(tipo).length, texto, classe]) .filter(([, quantidade]) => quantidade > 0) .map( ([tipo, quantidade, texto, classe]) => `` ) .join(""); cardsEl.innerHTML = `
    ${kpi( "Pagamentos a vincular", pidConcMoeda(soma(aVincular, "total_nao_vinculado")), `${aVincular.length} fornecedor(es)`, "conc-kpi--warning", "pagamento_sem_titulo" )} ${kpi( "Títulos em aberto", pidConcMoeda(soma(emAberto, "total_em_aberto")), `${emAberto.length} fornecedor(es) · disponíveis para vincular os pagamentos`, "", "" )} ${kpi("Críticos pendentes", `${criticasPendentes} fornecedor(es)`, "em situação Crítico, sem validação", "conc-kpi--danger", "criticos_pendentes")}
    Validação${validados} de ${contas.length} fornecedores
    ${validados ? `` : ""} ${contas.length - validados ? `` : ""}
    ${apontamentos ? `
    Apontamentos:${apontamentos}
    ` : ""}`; // Filtro de card/etiqueta vale sobre todas as contas, validadas ou não, // para a lista bater com o número mostrado; os chips ficam inativos. cardsEl.querySelectorAll("[data-conc-alerta]").forEach((el) => { el.addEventListener("click", () => { const alerta = el.dataset.concAlerta; filtroAlerta = filtroAlerta === alerta ? "" : alerta; mostrarAba("conciliacao"); renderCards(); renderContas(); }); }); abaVencidosQtd.textContent = vencidas.length - vencidasValidadas; filtroGrupoBtns.forEach((btn) => { const grupo = btn.dataset.concGrupo; const quantidade = contas.filter(PID_CONC_GRUPOS[grupo]).length; btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} ${quantidade}`; btn.classList.toggle("is-active", !filtroAlerta && filtroGrupo === grupo); }); // Coluna "Ajuste" só nos chips de validadas (pedido do usuário); o // filtro fica no cabeçalho dela, com o ícone do ajuste filtrado. const mostraAjuste = filtroGrupo !== "pendentes" && !filtroAlerta; contasTable.classList.toggle("conc-table--sem-ajuste", !mostraAjuste); ajusteThBtn.classList.toggle("is-active", Boolean(filtroAjuste)); ajusteThAtivo.innerHTML = filtroAjuste ? `${PID_CONC_AJUSTES[filtroAjuste].icone}` : ""; ajusteThBtn.setAttribute("aria-label", filtroAjuste ? `Ajuste: filtrando por ${PID_CONC_AJUSTES[filtroAjuste].rotulo}` : "Filtrar por ajuste"); } // ---- Menu do filtro de ajuste, no cabeçalho da coluna ---- // Fixo no body (como o balão), porque o cabeçalho é sticky e a tabela // cortaria um menu absoluto. Fecha ao escolher, ao clicar fora, com Esc // e ao rolar. const ajusteThBtn = document.getElementById("conc-ajuste-th"); const ajusteThAtivo = document.getElementById("conc-ajuste-th-ativo"); let ajusteMenu = null; function fecharMenuAjuste() { if (ajusteMenu) ajusteMenu.hidden = true; ajusteThBtn.setAttribute("aria-expanded", "false"); } function abrirMenuAjuste() { if (!ajusteMenu) { ajusteMenu = document.createElement("div"); ajusteMenu.className = "conc-ajuste-menu"; ajusteMenu.setAttribute("role", "menu"); ajusteMenu.hidden = true; document.body.appendChild(ajusteMenu); ajusteMenu.addEventListener("click", (evento) => { const opcao = evento.target.closest("[data-conc-ajuste]"); if (!opcao) return; filtroAjuste = opcao.dataset.concAjuste; fecharMenuAjuste(); renderCards(); renderContas(); }); } const doGrupo = detalhe.contas.filter(PID_CONC_GRUPOS[filtroGrupo]); const opcoes = [["", "Todos", doGrupo.length]].concat( Object.keys(PID_CONC_AJUSTES).map((chave) => [chave, PID_CONC_AJUSTES[chave].rotulo, doGrupo.filter((c) => pidConcAjustesConta(c)[chave]).length]) ); ajusteMenu.innerHTML = opcoes .map( ([chave, rotulo, quantidade]) => ` ` ) .join(""); ajusteMenu.hidden = false; const rect = ajusteThBtn.getBoundingClientRect(); const largura = ajusteMenu.offsetWidth; ajusteMenu.style.left = `${Math.max(8, Math.min(rect.left + rect.width / 2 - largura / 2, window.innerWidth - largura - 8))}px`; ajusteMenu.style.top = `${rect.bottom + 6}px`; ajusteThBtn.setAttribute("aria-expanded", "true"); } ajusteThBtn.addEventListener("click", (evento) => { // não ordena nem conta como "clicar fora" para recolher evento.stopPropagation(); if (ajusteMenu && !ajusteMenu.hidden) fecharMenuAjuste(); else if (detalhe) abrirMenuAjuste(); }); document.addEventListener("click", (evento) => { if (ajusteMenu && !ajusteMenu.hidden && !ajusteMenu.contains(evento.target)) fecharMenuAjuste(); }); document.addEventListener("keydown", (evento) => { if (evento.key === "Escape") fecharMenuAjuste(); }); window.addEventListener("scroll", fecharMenuAjuste, true); filtroGrupoBtns.forEach((btn) => { btn.addEventListener("click", () => { filtroGrupo = btn.dataset.concGrupo; filtroAjuste = ""; filtroAlerta = ""; restauraSortPadrao(); atualizarSortHeaders(); renderCards(); renderContas(); }); }); contasBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderContas())); const sortValor = { conta_numero: (c) => Number(c.conta_numero), fornecedor: (c) => c.fornecedor, saldo_final: (c) => Number(c.saldo_final), status: (c) => PID_CONC_STATUS[c.status].rank, validada_em: (c) => (c.validada && c.validada_em) || "", }; sortHeaders.forEach((th) => { th.addEventListener("click", () => { const chave = th.dataset.sort; sortDir = sortKey === chave ? sortDir * -1 : 1; sortKey = chave; atualizarSortHeaders(); renderContas(); }); }); function atualizarSortHeaders() { sortHeaders.forEach((h) => { h.classList.toggle("is-sort-asc", h.dataset.sort === sortKey && sortDir === 1); h.classList.toggle("is-sort-desc", h.dataset.sort === sortKey && sortDir === -1); }); } /** A conta aparece com o filtro atual? Usado pela lista inteira e depois * de cada ajuste numa conta (`aplicarRespostaConta`). */ function contaPassaFiltro(c) { const termo = contasBusca.value.trim().toLowerCase(); if (filtroAlerta) { let temAlerta; if (filtroAlerta === "criticos_pendentes") temAlerta = c.status === "divergente" && !c.validada; else if (filtroAlerta === "debito_excedente") temAlerta = Number(c.total_excedente) > 0; else temAlerta = c.alertas.some((a) => a.tipo === filtroAlerta); if (!temAlerta) return false; } else if (!PID_CONC_GRUPOS[filtroGrupo](c)) { return false; } else if (filtroAjuste && !pidConcAjustesConta(c)[filtroAjuste]) { return false; } return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo); } /** Tolerância da conciliação para um total de títulos (o maior entre o * valor fixo e o percentual), mesma regra do servidor. */ function limiteTolerancia(totalCreditos) { return Math.max(Number(detalhe.tolerancia_valor), (totalCreditos * Number(detalhe.tolerancia_percentual)) / 100); } const ROTULO_GRUPO = { pendentes: "Pendentes", parcial: "Parcial", saldo_aberto: "Saldo em aberto", conciliados: "Conciliados", validados: "Validados", }; /** Validar/desvalidar tira a conta da lista atual quando ela muda de chip * (pedido do usuário: ela "sumia" sem aviso). Nesse caso pergunta antes, * dizendo para qual chip ela vai; se continua na lista, não pergunta. * Validar não muda saldo nem vínculos, então o destino é previsível. */ async function confirmaMudancaDeChip(conta, validada) { const depois = { ...conta, validada }; if (contaPassaFiltro(depois)) return true; const destino = Object.keys(PID_CONC_GRUPOS).find((g) => g !== "validados" && PID_CONC_GRUPOS[g](depois)); const nome = `${conta.conta_numero} - ${conta.fornecedor}`; const mensagem = validada ? `"${nome}" será marcada como validada e sairá desta lista. Ela passa a aparecer em "${ROTULO_GRUPO[destino]}" (e em "Validados").` : `A validação de "${nome}" será removida e ela sairá desta lista. Ela volta para "${ROTULO_GRUPO[destino]}".`; return pidConfirm(mensagem, { titulo: validada ? "Validar fornecedor" : "Remover validação", textoConfirmar: validada ? "Validar" : "Remover validação", }); } function contasFiltradas() { let lista = detalhe.contas.filter(contaPassaFiltro); if (sortKey) { const extrator = sortValor[sortKey]; lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir); } return lista; } // Clicar fora dos fornecedores expandidos recolhe todos (pedido do // usuário). "Fora" = fora das linhas da tabela de fornecedores (a linha e // a expansão dela). Não conta: clique em modal ou balão (abertos a partir // da própria expansão), clique em elemento que a ação do clique já tirou // da página (re-render depois de vincular, desfazer...) e clique que // termina uma seleção de texto (para copiar). Os rascunhos de observação // sobrevivem ao recolher. document.addEventListener("click", (evento) => { if (!detalhe || detailView.hidden || abaAtual !== "conciliacao" || !abertas.size) return; const alvo = evento.target; if (!(alvo instanceof Element) || !document.contains(alvo)) return; if (contasBody.contains(alvo) || alvo.closest(".modal-overlay, .conc-tooltip")) return; if (pidConcFoiArraste(evento)) return; abertas.clear(); renderContas(); }); function textoListaVazia() { return filtroGrupo === "pendentes" && !filtroAlerta && !contasBusca.value.trim() ? "Todos os fornecedores estão validados." : "Nenhum fornecedor para este filtro."; } function renderContas() { filtroAlertaEl.hidden = !filtroAlerta; if (filtroAlerta) { filtroAlertaEl.innerHTML = `Filtrando por: ${PID_CONC_ROTULO_ALERTA[filtroAlerta]}`; filtroAlertaEl.querySelector("button").addEventListener("click", () => { filtroAlerta = ""; renderCards(); renderContas(); }); } const lista = contasFiltradas(); contasBody.innerHTML = ""; contasVazia.hidden = lista.length > 0; contasVazia.textContent = textoListaVazia(); const fragmento = document.createDocumentFragment(); lista.forEach((conta) => montarLinhasConta(conta).forEach((tr) => fragmento.appendChild(tr))); contasBody.appendChild(fragmento); atualizarUrl(); } function resumoAlertas(conta) { // sem alerta, o que sobra pendente são só títulos (pagamento não vinculado é alerta) if (!conta.alertas.length) return conta.qtd_pendentes ? `${conta.qtd_pendentes} título(s) em aberto, sem pagamento a conciliar` : ""; // só o primeiro alerta, cortado com reticências: o resto está na expansão return conta.alertas[0].mensagem; } function montarLinhasConta(conta) { const aberta = abertas.has(conta.id); const tr = document.createElement("tr"); tr.className = `conc-row${aberta ? " is-aberta" : ""}`; tr.dataset.contaId = conta.id; const saldoFinal = Number(conta.saldo_final); tr.innerHTML = ` ${PID_CONC_CHEVRON} ${pidConcEscape(conta.conta_numero)} ${pidConcEscape(conta.fornecedor)}${pidConcEscape(resumoAlertas(conta))} ${pidConcSaldo(conta.saldo_final)} ${pidConcAjustesHtml(conta)} ${pidConcStatusHtml(conta.status)} ${ conta.validada && conta.validada_em ? `${pidConcDataHora(conta.validada_em)}` : "" }`; const check = tr.querySelector("[data-validado-check]"); // o clique no checkbox não expande/recolhe a linha check.addEventListener("click", (evento) => evento.stopPropagation()); check.addEventListener("change", async () => { check.disabled = true; if (!(await confirmaMudancaDeChip(conta, check.checked))) { check.checked = !check.checked; check.disabled = false; return; } try { // sem `observacao` no corpo: o servidor mantém a já registrada const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, { method: "POST", body: { validada: check.checked }, }); aplicarRespostaConta(resposta); } catch (erro) { check.checked = !check.checked; check.disabled = false; pidAlert(erro.message, { titulo: "Não foi possível salvar" }); } }); // Copiar texto da linha (pedido do usuário): arrastar para selecionar // não expande, e o duplo/triplo clique (selecionar palavra/linha) // também não; por isso o clique simples espera um instante antes de // expandir, para saber se vem um segundo clique. // O critério é o arraste, não haver texto selecionado: clicar em cima de // uma seleção que já existia ainda a vê no momento do clique (o // navegador só a desfaz depois), e o clique era ignorado. let esperaClique = null; tr.addEventListener("click", (evento) => { if (evento.detail > 1) { clearTimeout(esperaClique); return; } if (pidConcFoiArraste(evento)) return; esperaClique = setTimeout(() => { if (abertas.has(conta.id)) abertas.delete(conta.id); else abertas.add(conta.id); substituirConta(conta); }, 200); }); if (!aberta) return [tr]; const trDetalhe = document.createElement("tr"); trDetalhe.className = "conc-detail-row"; trDetalhe.dataset.contaDetalheId = conta.id; const td = document.createElement("td"); td.colSpan = 7; td.className = "conc-detail-cell"; td.appendChild(montarDetalheConta(conta)); trDetalhe.appendChild(td); return [tr, trDetalhe]; } /** Re-renderiza só as linhas de uma conta (a principal e o detalhe), no lugar. */ function substituirConta(conta) { const antiga = contasBody.querySelector(`tr[data-conta-id="${conta.id}"]`); if (!antiga) return; const detalheAntigo = contasBody.querySelector(`tr[data-conta-detalhe-id="${conta.id}"]`); const novas = montarLinhasConta(conta); antiga.replaceWith(...novas); if (detalheAntigo) detalheAntigo.remove(); } function aplicarRespostaConta(resposta) { const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id); const eraValidada = indice >= 0 && detalhe.contas[indice].validada; if (indice >= 0) detalhe.contas[indice] = resposta.conta; // Conta que acabou de ser validada (checkbox, botão ou validação // automática ao ficar conciliada) recolhe a expansão: o trabalho nela // terminou (pedido do usuário). if (!eraValidada && resposta.conta.validada) abertas.delete(resposta.conta.id); detalhe.resumo = resposta.resumo; const estado = estadoDe(resposta.conta.id); estado.selecionados.clear(); renderCards(); // Conta que deixou de caber no filtro (validada no chip "Pendentes", // desmarcada num chip de validadas, mudou de grupo) sai da lista na hora. Antes ela só era // redesenhada no lugar e saía no próximo render da lista inteira // (pedido do usuário: "tem um atraso, às vezes preciso atualizar"). if (contaPassaFiltro(resposta.conta)) substituirConta(resposta.conta); else removerConta(resposta.conta.id); } /** Tira as linhas da conta da lista com um fade curto. */ function removerConta(contaId) { const linhas = contasBody.querySelectorAll(`tr[data-conta-id="${contaId}"], tr[data-conta-detalhe-id="${contaId}"]`); linhas.forEach((tr) => tr.classList.add("is-saindo")); window.setTimeout(() => { linhas.forEach((tr) => tr.remove()); if (!contasBody.querySelector("tr[data-conta-id]")) { contasVazia.hidden = false; contasVazia.textContent = textoListaVazia(); } }, 160); } function linhaLancamentoHtml(lanc, comCheckbox, selecionado) { const situacao = lanc.situacao; const valorAberto = pidConcValorAbertoHtml(situacao); return ` ${comCheckbox ? `` : ""} ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} ${pidConcHistoricoHtml(lanc)} ${lanc.natureza === "C" ? "Crédito" : "Débito"} ${pidConcMoeda(lanc.valor)} ${comCheckbox ? `${valorAberto}${pidConcSituacaoHtml(situacao)}` : ""} `; } function montarDetalheConta(conta) { const estado = estadoDe(conta.id); const painel = document.createElement("div"); painel.className = "conc-detail"; if (conta.alertas.length) { const lista = document.createElement("ul"); lista.className = "conc-alertas"; lista.innerHTML = conta.alertas .map( (a) => `
  • ${PID_CONC_SEVERIDADE[a.severidade]}${pidConcEscape(a.mensagem)}
  • ` ) .join(""); painel.appendChild(lista); } // ---- Razão desde a primeira pendência ---- // Pedido do usuário: mostrar o razão inteiro a partir do lançamento // pendente mais antigo (inclusive o que já foi vinculado nesse período), // para enxergar vínculos automáticos errados e desfazê-los ali mesmo. // "Pendente" inclui os itens de um pagamento parcial, cujo título segue // em aberto. const pendentes = conta.lancamentos.filter((l) => l.vinculo_id === null); const numeroVinculo = new Map(conta.vinculos.map((v, i) => [v.id, i + 1])); const vinculoPorId = new Map(conta.vinculos.map((v) => [v.id, v])); const idsParciais = new Set(conta.vinculos.filter((v) => v.tipo === "parcial").map((v) => v.id)); // Proporção créditos:débitos de cada vínculo ("4:1" = 4 créditos para 1 // débito), no lugar do nome do tipo (pedido do usuário). const proporcaoVinculo = new Map(); conta.lancamentos.forEach((l) => { if (!l.vinculo_id) return; const p = proporcaoVinculo.get(l.vinculo_id) || { c: 0, d: 0 }; if (l.natureza === "C") p.c += 1; else p.d += 1; proporcaoVinculo.set(l.vinculo_id, p); }); // Vínculos manuais fechados com diferença (desconto ou juros): aparecem // no razão como o parcelamento, com uma linha própria da diferença // (pedido do usuário, rodada 76 do CHANGELOG). const ajusteFechado = new Map( conta.vinculos .filter((v) => v.tipo === "manual") .map((v) => [v.id, pidConcAjusteVinculo(v)]) .filter(([, ajuste]) => ajuste === "desconto" || ajuste === "juros") ); const datasPendencia = conta.lancamentos .filter((l) => l.vinculo_id === null || idsParciais.has(l.vinculo_id) || ajusteFechado.has(l.vinculo_id)) .map((l) => l.data); const inicioPendencia = datasPendencia.length ? datasPendencia.reduce((a, b) => (a < b ? a : b)) : null; const razao = inicioPendencia ? conta.lancamentos.filter((l) => l.data >= inicioPendencia) : []; const qtdVinculadosRazao = razao.filter((l) => l.vinculo_id !== null).length; const secaoPend = document.createElement("div"); secaoPend.innerHTML = `

    Razão desde ${inicioPendencia ? pidConcData(inicioPendencia) : "—"} · ${pendentes.length} não conciliado(s)

    ${qtdVinculadosRazao ? `` : ""}
    `; if (razao.length) { const tabela = document.createElement("table"); tabela.className = "conc-subtabela conc-subtabela--pendentes"; tabela.classList.toggle("is-ocultando-conciliados", Boolean(estado.ocultarConciliados)); tabela.innerHTML = ` DataSeq.HistóricoNaturezaValorSaldoEm abertoSituação ${linhasRazaoHtml()}`; secaoPend.appendChild(tabela); } else { secaoPend.insertAdjacentHTML("beforeend", '

    Todos os lançamentos desta conta estão vinculados.

    '); } painel.appendChild(secaoPend); // Linha temporária do saldo a pagar de um pagamento parcial (pedido do // usuário): logo abaixo do último crédito (título) do vínculo no razão, // com a diferença que continua em aberto. Sem crédito no razão, abaixo do // último lançamento do vínculo. Não é lançamento do arquivo (sem seleção // nem saldo); some sozinha quando o vínculo fecha ou é desfeito. function linhasRazaoHtml() { const ultimoDoParcial = new Map(); const temCredito = new Set(); razao.forEach((l) => { if (!idsParciais.has(l.vinculo_id) && !ajusteFechado.has(l.vinculo_id)) return; if (l.natureza === "C") { ultimoDoParcial.set(l.vinculo_id, l.id); temCredito.add(l.vinculo_id); } else if (!temCredito.has(l.vinculo_id)) { ultimoDoParcial.set(l.vinculo_id, l.id); } }); return razao .map((l) => { const linha = linhaRazaoHtml(l); if (ultimoDoParcial.get(l.vinculo_id) !== l.id) return linha; const vinculo = vinculoPorId.get(l.vinculo_id); return linha + (ajusteFechado.has(vinculo.id) ? linhaAjusteHtml(vinculo, ajusteFechado.get(vinculo.id)) : linhaSaldoParcialHtml(vinculo)); }) .join(""); } // Linha da diferença de um vínculo fechado com desconto (títulos maiores // que os pagamentos) ou juros (pagamentos maiores), no mesmo lugar e // formato da linha do saldo a pagar do parcelamento, mas sem seleção e // sem valor em aberto: o vínculo está fechado. function linhaAjusteHtml(vinculo, ajuste) { const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id); const creditos = itens.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); const debitos = itens.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0); const numero = numeroVinculo.get(vinculo.id) || "?"; const rotulo = PID_CONC_AJUSTES[ajuste].rotulo; const contrapartida = `Pagos ${pidConcMoeda(debitos)} de ${pidConcMoeda(creditos)} em títulos`; const situacao = ajuste === "desconto" ? "Vínculo fechado com desconto (pago a menor)." : "Vínculo fechado com juros/multa (pago a maior)."; return ` — — ${pidConcHistoricoHtml({ historico: `${rotulo} do vínculo #${numero}`, contrapartida })} ${rotulo} ${pidConcMoeda(Math.abs(Number(vinculo.diferenca)))} — — ${situacao} `; } function linhaSaldoParcialHtml(vinculo) { const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id); const creditos = itens.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); const debitos = itens.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0); const numero = numeroVinculo.get(vinculo.id) || "?"; const situacao = vinculo.situacao || { categoria: "em_aberto", valor_em_aberto: vinculo.diferenca, descricao: "Saldo do pagamento parcial", sugestao: "" }; // Selecionável (pedido do usuário): com o saldo marcado, a barra // acrescenta os demais selecionados a este vínculo em vez de criar outro. // Id negativo (-id do vínculo), o mesmo das sugestões. const selecionado = estado.selecionados.has(-vinculo.id); return ` — — ${pidConcHistoricoHtml({ historico: `Saldo a pagar do vínculo #${numero}`, contrapartida: `Pagos ${pidConcMoeda(debitos)} de ${pidConcMoeda(creditos)} em títulos` })} A pagar ${pidConcMoeda(vinculo.diferenca)} — ${pidConcValorAbertoHtml(situacao)} ${pidConcSituacaoHtml(situacao)} `; } function linhaRazaoHtml(lanc) { // Saldo do Questor só onde ainda há saldo (pedido do usuário): na // linha de vínculo fechado vira "—"; fica no lançamento pendente e no // vínculo parcial com saldo a pagar. const vinculoDaLinha = lanc.vinculo_id === null ? null : vinculoPorId.get(lanc.vinculo_id); const mostraSaldo = lanc.saldo_apos !== null && (!vinculoDaLinha || (vinculoDaLinha.tipo === "parcial" && Number(vinculoDaLinha.diferenca) > 0)); const comum = ` ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} ${pidConcHistoricoHtml(lanc)} ${lanc.natureza === "C" ? "Crédito" : "Débito"} ${pidConcMoeda(lanc.valor)} ${mostraSaldo ? pidConcSaldo(lanc.saldo_apos) : "—"}`; if (lanc.vinculo_id === null) { const selecionado = estado.selecionados.has(lanc.id); // "Em aberto" do razão é só o saldo de pagamento parcial (linha // própria, `linhaSaldoParcialHtml`), pedido do usuário: o lançamento // sem vínculo não repete aqui o próprio valor. return ` ${comum} — ${pidConcSituacaoHtml(lanc.situacao)} `; } const vinculo = vinculoPorId.get(lanc.vinculo_id); const origem = vinculo && (vinculo.tipo === "manual" || vinculo.tipo === "parcial") ? "manual" : "automático"; const proporcao = proporcaoVinculo.get(lanc.vinculo_id) || { c: 0, d: 0 }; const dicaTipo = `${vinculo ? vinculo.tipo_rotulo : "Vínculo"}: ${proporcao.c} crédito(s) para ${proporcao.d} débito(s)`; return ` ${comum} — #${numeroVinculo.get(lanc.vinculo_id) || "?"} ${proporcao.c}:${proporcao.d} · ${origem} `; } async function desfazerVinculo(vinculoId) { const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId); const lista = itens .map((l) => `${pidConcData(l.data)} ${l.natureza === "C" ? "crédito" : "débito"} ${pidConcMoeda(l.valor)}`) .join("; "); // Conta validada perde a validação ao desfazer (servidor, rodada 78). const avisoValidacao = conta.validada ? ' A conta está validada: a validação será removida e ela volta para "Pendentes".' : ""; const confirmado = await pidConfirm( `Desfazer o vínculo #${numeroVinculo.get(vinculoId) || ""} (${lista})? Os lançamentos voltam para não conciliados.${avisoValidacao}`, { titulo: "Desfazer vínculo", textoConfirmar: "Desfazer" } ); if (!confirmado) return; try { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculoId}/`, { method: "DELETE" }); aplicarRespostaConta(resposta); } catch (erro) { pidAlert(erro.message, { titulo: "Não foi possível desfazer" }); } } // Linhas vinculadas: destacar os outros itens do mesmo vínculo ao passar // o mouse, e "Desfazer" direto na linha. secaoPend.querySelectorAll("tr[data-vinculo-id]").forEach((tr) => { const grupo = () => secaoPend.querySelectorAll(`tr[data-vinculo-id="${tr.dataset.vinculoId}"]`); tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo"))); tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-grupo"))); }); // Sugestão: ao passar o mouse, destaca a própria linha e as do outro // lado da sugestão (pedido do usuário, como o destaque do vínculo). Id // negativo é o saldo de um vínculo parcial: destaca as linhas dele. secaoPend.querySelectorAll("[data-conc-relacionados]").forEach((sugestaoEl) => { const linha = sugestaoEl.closest("tr"); const ids = sugestaoEl.dataset.concRelacionados.split(",").filter(Boolean).map(Number); const grupo = () => [ linha, ...ids.flatMap((id) => Array.from(secaoPend.querySelectorAll(id < 0 ? `tr[data-vinculo-id="${-id}"]` : `tr[data-lanc-id="${id}"]`)) ), ]; sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo"))); sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo"))); // Duplo clique seleciona a linha e as relacionadas; se já estavam todas // selecionadas, desmarca (pedido do usuário). Só as com checkbox: as não // vinculadas e a linha do saldo a pagar de um pagamento parcial. sugestaoEl.addEventListener("dblclick", (evento) => { evento.stopPropagation(); if (window.getSelection) window.getSelection().removeAllRanges(); const checks = grupo() .map((tr) => tr.querySelector("[data-lanc-check]")) .filter(Boolean); const marcar = !checks.every((check) => check.checked); checks.forEach((check) => { if (check.checked === marcar) return; check.checked = marcar; check.dispatchEvent(new Event("change")); }); }); }); secaoPend.querySelector("[data-historico-vinculos]").addEventListener("click", (evento) => { evento.stopPropagation(); pidConcAbreHistoricoVinculos(conta); }); // "Ocultar conciliados": desmarcado por padrão (pedido do usuário); a // escolha fica no estado da conta e sobrevive ao re-render após um vínculo. const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]"); if (ocultarCheck) { ocultarCheck.addEventListener("click", (evento) => evento.stopPropagation()); ocultarCheck.addEventListener("change", () => { estado.ocultarConciliados = ocultarCheck.checked; const tabelaRazao = secaoPend.querySelector(".conc-subtabela--pendentes"); if (tabelaRazao) tabelaRazao.classList.toggle("is-ocultando-conciliados", ocultarCheck.checked); }); } secaoPend.querySelectorAll("[data-ver-vinculo]").forEach((btn) => { btn.addEventListener("click", (evento) => { evento.stopPropagation(); const vinculoId = Number(btn.dataset.verVinculo); pidConcAbreDetalheVinculo(conta, vinculoId, numeroVinculo.get(vinculoId)); }); }); secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => { btn.addEventListener("click", (evento) => { evento.stopPropagation(); desfazerVinculo(Number(btn.dataset.desfazerVinculo)); }); }); const resumoSelecaoEl = secaoPend.querySelector("[data-selecao-resumo]"); const todosCheck = secaoPend.querySelector("[data-lanc-todos]"); const vincularBtn = secaoPend.querySelector("[data-vincular]"); const porId = new Map(conta.lancamentos.map((l) => [l.id, l])); // O saldo a pagar de cada pagamento parcial entra na seleção como um // crédito a mais (id negativo = -id do vínculo), só para a soma da barra // e para escolher o vínculo que recebe os demais selecionados. conta.vinculos .filter((v) => v.tipo === "parcial") .forEach((v) => porId.set(-v.id, { id: -v.id, natureza: "C", valor: v.diferenca, saldoDoVinculo: v })); function atualizarSelecao() { const selecionados = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean); const lancamentos = selecionados.filter((l) => !l.saldoDoVinculo); const saldos = selecionados.filter((l) => l.saldoDoVinculo); const creditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); const debitos = selecionados.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0); if (saldos.length) { // Com um saldo marcado, a barra acrescenta ao vínculo dele. vincularBtn.textContent = saldos.length === 1 ? `Adicionar ao vínculo #${numeroVinculo.get(saldos[0].saldoDoVinculo.id) || ""}` : "Marque só um saldo a pagar"; vincularBtn.disabled = saldos.length !== 1 || lancamentos.length === 0; } else { const temAmbos = lancamentos.some((l) => l.natureza === "C") && lancamentos.some((l) => l.natureza === "D"); vincularBtn.textContent = "Vincular selecionados"; vincularBtn.disabled = !temAmbos; } // A dica fica no invólucro: botão desabilitado não recebe o mouse // (`pointer-events: none` no CSS), e ela só explica o botão desabilitado. painel.querySelectorAll("[data-adicionar]").forEach((btn) => { btn.disabled = lancamentos.length === 0; const envolto = btn.closest("[data-adicionar-dica]"); if (btn.disabled) { envolto.dataset.concTooltip = "Marque lançamentos na lista de não conciliados"; envolto.dataset.concTooltipSempre = ""; } else { delete envolto.dataset.concTooltip; delete envolto.dataset.concTooltipSempre; } }); if (todosCheck) { todosCheck.checked = lancamentos.length > 0 && lancamentos.length === pendentes.length; todosCheck.indeterminate = lancamentos.length > 0 && lancamentos.length < pendentes.length; todosCheck.dataset.concTooltip = lancamentos.length ? "Desmarcar todos" : "Marcar todos"; } resumoSelecaoEl.innerHTML = selecionados.length ? `Créditos ${pidConcMoeda(creditos)} · Débitos ${pidConcMoeda(debitos)} · Diferença ${pidConcMoeda(creditos - debitos)}` : "Selecione débitos e créditos para vincular manualmente."; return { selecionados: lancamentos, saldos, diferenca: Math.round((creditos - debitos) * 100) / 100 }; } atualizarSelecao(); // Cabeçalho: sem nada marcado, marca todos; com algum marcado (parcial ou // todos), desmarca todos. "Todos" são os lançamentos; o saldo a pagar só // é desmarcado junto, nunca marcado (vale um vínculo por vez). if (todosCheck) { todosCheck.addEventListener("change", () => { const marcar = estado.selecionados.size === 0; secaoPend.querySelectorAll(marcar ? "[data-lanc-check]:not([data-saldo-check])" : "[data-lanc-check]").forEach((check) => { check.checked = marcar; check.closest("tr").classList.toggle("is-selecionado", marcar); const id = Number(check.dataset.lancCheck); if (marcar) estado.selecionados.add(id); else estado.selecionados.delete(id); }); atualizarSelecao(); }); } // A linha inteira marca/desmarca (pedido do usuário); o checkbox fica // como indicador. Clique no próprio checkbox segue o comportamento // nativo, e arrastar para selecionar texto não conta como clique. secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id]), .conc-subtabela--pendentes tbody tr[data-saldo-vinculo]").forEach((tr) => { tr.addEventListener("click", (evento) => { if (evento.target.closest("[data-lanc-check]")) return; // A sugestão é reservada ao duplo clique (seleciona o grupo); o // clique simples nela marcaria/desmarcaria a linha duas vezes antes. if (evento.target.closest("[data-conc-relacionados]")) return; if (pidConcFoiArraste(evento)) return; const check = tr.querySelector("[data-lanc-check]"); check.checked = !check.checked; check.dispatchEvent(new Event("change")); }); }); secaoPend.querySelectorAll("[data-lanc-check]").forEach((check) => { check.addEventListener("change", () => { const id = Number(check.dataset.lancCheck); if (check.checked) estado.selecionados.add(id); else estado.selecionados.delete(id); check.closest("tr").classList.toggle("is-selecionado", check.checked); atualizarSelecao(); }); }); // Acrescenta lançamentos a um vínculo manual/parcial (botão do bloco do // vínculo, ou a barra com o saldo a pagar dele marcado). async function adicionarAoVinculo(vinculo, novos, botao) { if (!novos.length) return; const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id); const assinado = (l) => (l.natureza === "C" ? Number(l.valor) : -Number(l.valor)); const novaDiferenca = [...itens, ...novos].reduce((t, l) => t + assinado(l), 0); const arredondada = Math.round(novaDiferenca * 100) / 100; const numero = numeroVinculo.get(vinculo.id) || ""; const totalCreditos = [...itens, ...novos].filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); let fechar = false; if (arredondada > limiteTolerancia(totalCreditos)) { // mesma escolha da criação: parcelamento ou desconto const escolha = await pidConcEscolheAjuste( `Com ${novos.length} lançamento(s) a mais, o vínculo #${numero} fica com ${pidConcMoeda(arredondada)} de títulos sem pagamento. Como tratar a diferença?`, arredondada ); if (!escolha) return; fechar = escolha === "desconto"; } else { const efeito = arredondada > 0 ? `fecha com ${pidConcMoeda(arredondada)} de desconto` : arredondada < 0 ? `fecha pago a maior em ${pidConcMoeda(-arredondada)} (juros)` : "fecha sem diferença"; const seguir = await pidConfirm( `Adicionar ${novos.length} lançamento(s) ao vínculo #${numero}? Com eles, o vínculo ${efeito}.`, { titulo: "Adicionar ao vínculo", textoConfirmar: "Adicionar" } ); if (!seguir) return; } botao.disabled = true; try { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculo.id}/adicionar/`, { method: "POST", body: { lancamentos: novos.map((l) => l.id), fechar }, }); aplicarRespostaConta(resposta); } catch (erro) { botao.disabled = false; pidAlert(erro.message, { titulo: "Não foi possível adicionar" }); } } vincularBtn.addEventListener("click", async () => { const { selecionados, saldos, diferenca } = atualizarSelecao(); if (saldos.length) { if (saldos.length === 1) adicionarAoVinculo(saldos[0].saldoDoVinculo, selecionados, vincularBtn); return; } // Mesmo critério do servidor (vincular/): créditos maiores que os // débitos além da tolerância = o contador escolhe entre parcelamento // (o título continua em aberto pelo saldo) e desconto (fecha). const totalCreditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); let fechar = false; if (diferenca > limiteTolerancia(totalCreditos)) { const escolha = await pidConcEscolheAjuste( `Os débitos selecionados pagam ${pidConcMoeda(totalCreditos - diferenca)} de ${pidConcMoeda(totalCreditos)} em títulos. Como tratar a diferença?`, diferenca ); if (!escolha) return; fechar = escolha === "desconto"; } else if (diferenca !== 0) { const natureza = diferenca < 0 ? "pago a maior: será registrada como juros" : "pago a menor: será registrada como desconto"; const seguir = await pidConfirm( `Os lançamentos selecionados têm diferença de ${pidConcMoeda(Math.abs(diferenca))} (${natureza}). Vincular mesmo assim? O vínculo fica sinalizado nos alertas da conta.`, { titulo: diferenca < 0 ? "Vínculo com juros" : "Vínculo com desconto", textoConfirmar: "Vincular" } ); if (!seguir) return; } vincularBtn.disabled = true; try { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/vincular/`, { method: "POST", body: { lancamentos: selecionados.map((l) => l.id), fechar }, }); aplicarRespostaConta(resposta); } catch (erro) { vincularBtn.disabled = false; pidAlert(erro.message, { titulo: "Não foi possível vincular" }); } }); // ---- Vinculados ---- function montarBlocoVinculo(vinculo) { const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id); const manual = vinculo.tipo === "manual" || vinculo.tipo === "parcial"; const origem = manual ? `manual, por ${pidConcEscape(vinculo.criado_por_nome || "—")}` : "automático"; const diferenca = Number(vinculo.diferenca); let detalheDiferenca = ""; if (vinculo.tipo === "parcial") { const saldo = vinculo.situacao; detalheDiferenca = ` · restam ${saldo ? pidConcValorAbertoHtml(saldo) : pidConcMoeda(diferenca)} em aberto${saldo ? ` · ${pidConcEscape(saldo.descricao)}` : ""}`; } else if (diferenca) { detalheDiferenca = ` · diferença ${pidConcMoeda(Math.abs(diferenca))} ${diferenca < 0 ? "(pago a maior)" : "(pago a menor)"}`; } const bloco = document.createElement("div"); bloco.className = `conc-vinculo${vinculo.tipo === "parcial" ? " conc-vinculo--parcial" : ""}`; bloco.innerHTML = `
    #${numeroVinculo.get(vinculo.id)} ${pidConcEscape(vinculo.tipo_rotulo)} · ${origem}${detalheDiferenca} ${manual ? '' : ""}
    ${itens.map((l) => linhaLancamentoHtml(l, false, false)).join("")}
    DataSeq.HistóricoNaturezaValor
    `; const adicionarBtn = bloco.querySelector("[data-adicionar]"); if (adicionarBtn) { adicionarBtn.addEventListener("click", () => { const novos = Array.from(estado.selecionados).map((id) => porId.get(id)).filter((l) => l && !l.saldoDoVinculo); adicionarAoVinculo(vinculo, novos, adicionarBtn); }); } bloco.querySelector("[data-desfazer]").addEventListener("click", () => desfazerVinculo(vinculo.id)); return bloco; } // Pagamento parcial não é conciliação fechada: fica junto dos não // conciliados, sempre visível, com o saldo que continua em aberto. const parciais = conta.vinculos.filter((v) => v.tipo === "parcial"); if (parciais.length) { const secaoParcial = document.createElement("div"); secaoParcial.innerHTML = `

    Pagamentos parciais vinculados (${parciais.length})

    `; parciais.forEach((v) => secaoParcial.appendChild(montarBlocoVinculo(v))); secaoPend.after(secaoParcial); } const fechados = conta.vinculos.filter((v) => v.tipo !== "parcial"); if (fechados.length) { const secaoVinc = document.createElement("div"); secaoVinc.innerHTML = `

    Lançamentos conciliados (${fechados.length} vínculo(s))

    `; secaoVinc.querySelector("[data-toggle-vinculados]").addEventListener("click", () => { estado.mostrarVinculados = !estado.mostrarVinculados; substituirConta(conta); }); if (estado.mostrarVinculados) { const lista = document.createElement("div"); lista.className = "conc-vinculos-lista"; fechados .slice() .reverse() .forEach((vinculo) => lista.appendChild(montarBlocoVinculo(vinculo))); secaoVinc.appendChild(lista); } painel.appendChild(secaoVinc); } // os botões "Adicionar selecionados" dos vínculos nascem depois da // primeira atualização da seleção atualizarSelecao(); // ---- Validação ---- const validacao = document.createElement("div"); validacao.className = "conc-validacao"; validacao.innerHTML = `
    ${conta.validada ? `${pidConcEscape(pidConcTextoValidacao(conta))}` : ""}
    `; const observacaoEl = validacao.querySelector("[data-observacao]"); observacaoEl.addEventListener("input", () => rascunhos.set(conta.id, observacaoEl.value)); const botoesValidacao = validacao.querySelectorAll("[data-salvar-obs], [data-validar]"); async function salvarValidacao(validada) { // travados até a resposta: um clique duplo mandaria duas vezes botoesValidacao.forEach((btn) => { btn.disabled = true; }); if (validada !== conta.validada && !(await confirmaMudancaDeChip(conta, validada))) { botoesValidacao.forEach((btn) => { btn.disabled = false; }); return; } try { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, { method: "POST", body: { validada, observacao: observacaoEl.value }, }); rascunhos.delete(conta.id); aplicarRespostaConta(resposta); } catch (erro) { botoesValidacao.forEach((btn) => { btn.disabled = false; }); pidAlert(erro.message, { titulo: "Não foi possível salvar" }); } } validacao.querySelector("[data-salvar-obs]").addEventListener("click", () => salvarValidacao(conta.validada)); validacao.querySelector("[data-validar]").addEventListener("click", () => salvarValidacao(!conta.validada)); validacao.querySelector("[data-recolher]").addEventListener("click", () => { abertas.delete(conta.id); substituirConta(conta); // a expansão pode ser longa: volta a tela para a linha do fornecedor const linha = contasBody.querySelector(`tr[data-conta-id="${conta.id}"]`); if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" }); }); painel.appendChild(validacao); return painel; } // ====================================================================== // Tela "Em aberto há mais de 3 meses" // ====================================================================== // Pedido do usuário: título antigo sem pagamento não é bloqueio para a // análise (não deixa mais a conta em "Atenção"), mas precisa de revisão // própria. Esta aba lista os fornecedores com esses títulos, com // validação e observação por fornecedor (`validar-vencidos/`), separadas // da validação da conciliação. const abaBtns = Array.from(document.querySelectorAll("[data-conc-aba]")); const abaPaineis = Array.from(document.querySelectorAll("[data-conc-painel]")); const abaVencidosQtd = document.getElementById("conc-aba-vencidos-qtd"); const vencBody = document.getElementById("conc-venc-body"); const vencVazia = document.getElementById("conc-venc-vazia"); const vencBusca = document.getElementById("conc-venc-busca"); const vencFiltroBtns = Array.from(document.querySelectorAll("[data-conc-venc]")); const vencSortHeaders = Array.from(document.querySelectorAll("#conc-venc-table th[data-venc-sort]")); let abaAtual = "conciliacao"; let vencFiltro = "pendentes"; let vencSortKey = "total_vencido"; let vencSortDir = -1; const vencAbertas = new Set(); function mostrarAba(aba) { abaAtual = aba; abaBtns.forEach((btn) => btn.classList.toggle("is-active", btn.dataset.concAba === aba)); abaPaineis.forEach((painel) => { painel.hidden = painel.dataset.concPainel !== aba; }); if (!detalhe) return; renderCards(); if (aba === "vencidos") renderVencidos(); else if (aba === "relatorio") carregarRelatorio(); else renderContas(); } abaBtns.forEach((btn) => btn.addEventListener("click", () => mostrarAba(btn.dataset.concAba))); // ---- Aba "Relatório" (pedidos do usuário, rodadas 71 e 72) ---- // Uso interno do contador: o servidor devolve um trecho de HTML no visual // do Portal (template com autoescape do Django), inserido aqui. Gerado de // novo a cada abertura da aba, porque a conciliação muda enquanto o // contador trabalha. const relatorioConteudo = document.getElementById("conc-relatorio-conteudo"); const relatorioGerado = document.getElementById("conc-relatorio-gerado"); const relatorioImprimirBtn = document.getElementById("conc-relatorio-imprimir"); const relatorioAnexoInput = document.getElementById("conc-relatorio-anexo"); let relatorioPedido = 0; async function carregarRelatorio() { const pedido = ++relatorioPedido; const conciliacaoId = detalhe.id; atualizarUrl(); relatorioGerado.textContent = "Gerando relatório…"; relatorioImprimirBtn.disabled = true; // relatório de outra conciliação não fica à vista enquanto carrega if (relatorioConteudo.dataset.conciliacaoId !== String(conciliacaoId)) relatorioConteudo.innerHTML = ""; relatorioConteudo.classList.add("is-carregando"); try { const resposta = await fetch(`${PID_API_BASE}/conciliacoes-fornecedores/${conciliacaoId}/relatorio/`, { credentials: "include" }); if (resposta.status === 401) { window.location.href = "index.html"; return; } if (!resposta.ok) throw new Error(`Não foi possível gerar o relatório (erro ${resposta.status}).`); const html = await resposta.text(); // trocou de conciliação ou pediu outro relatório no meio do caminho if (pedido !== relatorioPedido || !detalhe || detalhe.id !== conciliacaoId) return; relatorioConteudo.innerHTML = html; relatorioConteudo.dataset.conciliacaoId = String(conciliacaoId); relatorioGerado.textContent = `Situação em ${pidConcDataHora(new Date().toISOString())}`; relatorioImprimirBtn.disabled = false; } catch (erro) { if (pedido !== relatorioPedido) return; relatorioConteudo.innerHTML = ""; relatorioGerado.textContent = erro.message; } finally { if (pedido === relatorioPedido) relatorioConteudo.classList.remove("is-carregando"); } } document.getElementById("conc-relatorio-recarregar").addEventListener("click", () => { if (detalhe) carregarRelatorio(); }); // Impressão: uma cópia do relatório vai para um contêiner direto no body // (`#conc-impressao`) e o CSS de impressão esconde todo o resto do Portal, // com paleta clara fixa em A4. Na cópia, todos os blocos abrem; o anexo // só sai se marcado. relatorioImprimirBtn.addEventListener("click", () => { const original = relatorioConteudo.querySelector(".conc-rel"); if (!original) return; const impressao = document.createElement("div"); impressao.id = "conc-impressao"; const copia = original.cloneNode(true); copia.querySelectorAll("details").forEach((d) => { d.open = true; }); if (!relatorioAnexoInput.checked) copia.querySelectorAll(".conc-rel-anexo-secao").forEach((s) => s.remove()); impressao.appendChild(copia); document.body.appendChild(impressao); document.body.classList.add("conc-imprimindo"); const limpar = () => { document.body.classList.remove("conc-imprimindo"); impressao.remove(); window.removeEventListener("afterprint", limpar); }; window.addEventListener("afterprint", limpar); window.print(); }); function contasVencidas() { return detalhe.contas.filter((c) => Number(c.total_vencido) > 0); } /** Títulos da conta em aberto há mais de 3 meses, do mais antigo ao mais * recente: lançamentos sem vínculo e saldos de pagamento parcial. */ function titulosVencidos(conta) { const itens = conta.lancamentos .filter((l) => l.situacao && l.situacao.categoria === "vencido") .map((l) => ({ lanc: l, valor: l.situacao.valor_em_aberto })); conta.vinculos.forEach((v) => { if (!v.situacao || v.situacao.categoria !== "vencido") return; const titulo = conta.lancamentos .filter((l) => l.vinculo_id === v.id && l.natureza === "C") .sort((a, b) => a.data.localeCompare(b.data))[0]; if (titulo) { itens.push({ lanc: { ...titulo, historico: `${titulo.historico} (saldo do pagamento parcial)` }, valor: v.situacao.valor_em_aberto }); } }); return itens.sort((a, b) => a.lanc.data.localeCompare(b.lanc.data)); } function diasEmAberto(iso) { const dias = Math.round((Date.parse(detalhe.data_base) - Date.parse(iso.slice(0, 10))) / 86400000); return `${dias} dias`; } function textoValidacaoVencidos(conta) { if (!conta.vencidos_validados) return ""; return `Validado por ${conta.vencidos_validados_por_nome || "—"} em ${pidConcDataHora(conta.vencidos_validados_em)}`; } function vencPassaFiltro(c) { if (Number(c.total_vencido) <= 0) return false; if (vencFiltro === "pendentes" && c.vencidos_validados) return false; if (vencFiltro === "validados" && !c.vencidos_validados) return false; const termo = vencBusca.value.trim().toLowerCase(); return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo); } const vencSortValor = { conta_numero: (c) => Number(c.conta_numero), fornecedor: (c) => c.fornecedor, qtd_vencidos: (c) => c.qtd_vencidos, mais_antigo: (c) => (titulosVencidos(c)[0] || { lanc: { data: "" } }).lanc.data, total_vencido: (c) => Number(c.total_vencido), vencidos_validados: (c) => (c.vencidos_validados ? 1 : 0), }; vencSortHeaders.forEach((th) => { th.addEventListener("click", () => { const chave = th.dataset.vencSort; vencSortDir = vencSortKey === chave ? vencSortDir * -1 : 1; vencSortKey = chave; renderVencidos(); }); }); vencFiltroBtns.forEach((btn) => { btn.addEventListener("click", () => { vencFiltro = btn.dataset.concVenc; renderVencidos(); }); }); vencBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderVencidos())); function atualizarContagemVencidos() { const todas = contasVencidas(); const validadas = todas.filter((c) => c.vencidos_validados).length; const contagem = { pendentes: todas.length - validadas, validados: validadas, "": todas.length }; vencFiltroBtns.forEach((btn) => { const chave = btn.dataset.concVenc; btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} ${contagem[chave]}`; btn.classList.toggle("is-active", vencFiltro === chave); }); vencSortHeaders.forEach((h) => { h.classList.toggle("is-sort-asc", h.dataset.vencSort === vencSortKey && vencSortDir === 1); h.classList.toggle("is-sort-desc", h.dataset.vencSort === vencSortKey && vencSortDir === -1); }); return todas.length; } function mensagemVencidosVazia(totalComVencidos) { if (!totalComVencidos) return "Nenhum título em aberto há mais de 3 meses nesta conciliação."; if (vencFiltro === "pendentes" && !vencBusca.value.trim()) return "Todos os fornecedores com títulos antigos já foram validados."; return "Nenhum fornecedor para este filtro."; } function renderVencidos() { const total = atualizarContagemVencidos(); const extrator = vencSortValor[vencSortKey]; const lista = detalhe.contas .filter(vencPassaFiltro) .sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * vencSortDir); vencBody.innerHTML = ""; vencVazia.hidden = lista.length > 0; vencVazia.textContent = mensagemVencidosVazia(total); const fragmento = document.createDocumentFragment(); lista.forEach((conta) => montarLinhasVencidos(conta).forEach((tr) => fragmento.appendChild(tr))); vencBody.appendChild(fragmento); atualizarUrl(); } function montarLinhasVencidos(conta) { const aberta = vencAbertas.has(conta.id); const titulos = titulosVencidos(conta); const maisAntigo = titulos[0] ? titulos[0].lanc.data : null; const tr = document.createElement("tr"); tr.className = `conc-row${aberta ? " is-aberta" : ""}`; tr.dataset.vencContaId = conta.id; const subtitulo = textoValidacaoVencidos(conta) || conta.vencidos_observacao || ""; tr.innerHTML = ` ${PID_CONC_CHEVRON} ${pidConcEscape(conta.conta_numero)} ${pidConcEscape(conta.fornecedor)}${pidConcEscape(subtitulo)} ${titulos.length} ${pidConcData(maisAntigo)}${maisAntigo ? ` · ${diasEmAberto(maisAntigo)}` : ""} ${pidConcMoeda(conta.total_vencido)} `; const check = tr.querySelector("[data-venc-check]"); check.addEventListener("click", (evento) => evento.stopPropagation()); check.addEventListener("change", async () => { check.disabled = true; try { await salvarVencidos(conta, check.checked); } catch (erro) { check.checked = !check.checked; check.disabled = false; pidAlert(erro.message, { titulo: "Não foi possível salvar" }); } }); tr.addEventListener("click", () => { if (vencAbertas.has(conta.id)) vencAbertas.delete(conta.id); else vencAbertas.add(conta.id); substituirVencidos(conta); }); if (!aberta) return [tr]; const trDetalhe = document.createElement("tr"); trDetalhe.className = "conc-detail-row"; trDetalhe.dataset.vencDetalheId = conta.id; const td = document.createElement("td"); td.colSpan = 7; td.className = "conc-detail-cell"; td.appendChild(montarDetalheVencidos(conta, titulos)); trDetalhe.appendChild(td); return [tr, trDetalhe]; } function montarDetalheVencidos(conta, titulos) { const painel = document.createElement("div"); painel.className = "conc-detail"; painel.innerHTML = `

    Títulos em aberto há mais de 3 meses (${titulos.length})

    ${titulos .map( (t) => ` ${pidConcHistoricoHtml(t.lanc)} ` ) .join("")}
    DataSeq.HistóricoValor em abertoEm aberto há
    ${pidConcData(t.lanc.data)} ${t.lanc.eh_saldo_anterior ? "—" : pidConcEscape(t.lanc.sequencia || "—")}${pidConcMoeda(t.valor)} ${diasEmAberto(t.lanc.data)}
    ${conta.vencidos_validados ? `${pidConcEscape(textoValidacaoVencidos(conta))}` : ""}
    `; const observacaoEl = painel.querySelector("[data-venc-observacao]"); observacaoEl.addEventListener("input", () => rascunhosVenc.set(conta.id, observacaoEl.value)); const botoesValidacao = painel.querySelectorAll("[data-venc-salvar-obs], [data-venc-validar]"); const salvar = async (validada) => { botoesValidacao.forEach((btn) => { btn.disabled = true; }); try { await salvarVencidos(conta, validada, observacaoEl.value); } catch (erro) { botoesValidacao.forEach((btn) => { btn.disabled = false; }); pidAlert(erro.message, { titulo: "Não foi possível salvar" }); } }; painel.querySelector("[data-venc-salvar-obs]").addEventListener("click", () => salvar(conta.vencidos_validados)); painel.querySelector("[data-venc-validar]").addEventListener("click", () => salvar(!conta.vencidos_validados)); painel.querySelector("[data-venc-recolher]").addEventListener("click", () => { vencAbertas.delete(conta.id); substituirVencidos(conta); const linha = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`); if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" }); }); return painel; } function substituirVencidos(conta) { const antiga = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`); if (!antiga) return; const detalheAntigo = vencBody.querySelector(`tr[data-venc-detalhe-id="${conta.id}"]`); antiga.replaceWith(...montarLinhasVencidos(conta)); if (detalheAntigo) detalheAntigo.remove(); } function removerVencidos(contaId) { const linhas = vencBody.querySelectorAll(`tr[data-venc-conta-id="${contaId}"], tr[data-venc-detalhe-id="${contaId}"]`); linhas.forEach((tr) => tr.classList.add("is-saindo")); window.setTimeout(() => { linhas.forEach((tr) => tr.remove()); if (!vencBody.querySelector("tr[data-venc-conta-id]")) { vencVazia.hidden = false; vencVazia.textContent = mensagemVencidosVazia(contasVencidas().length); } }, 160); } /** Grava a validação/observação dos títulos antigos e atualiza a linha * (sai da lista com fade se deixar de caber no filtro, como na conciliação). */ async function salvarVencidos(conta, validada, observacao) { const corpo = { validada }; if (observacao !== undefined) corpo.observacao = observacao; const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar-vencidos/`, { method: "POST", body: corpo, }); if (observacao !== undefined) rascunhosVenc.delete(conta.id); const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id); const eraValidada = indice >= 0 && detalhe.contas[indice].vencidos_validados; if (indice >= 0) detalhe.contas[indice] = resposta.conta; if (!eraValidada && resposta.conta.vencidos_validados) vencAbertas.delete(resposta.conta.id); detalhe.resumo = resposta.resumo; renderCards(); atualizarContagemVencidos(); if (vencPassaFiltro(resposta.conta)) substituirVencidos(resposta.conta); else removerVencidos(resposta.conta.id); } // ====================================================================== // Início // ====================================================================== const hash = pidConcLeHash(); if (hash) { abrirDetalhe(hash.id, { params: hash.params }); } else { carregarHistorico(); } });