/** * 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: "Divergente", pill: "status-pill--danger", rank: 0 }, atencao: { rotulo: "Atenção", pill: "status-pill--warning", rank: 1 }, conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 2 }, }; const PID_CONC_CHEVRON = ''; const PID_CONC_ROTULO_ALERTA = { debito_excedente: "Pagamentos sem título em aberto", pagamento_sem_titulo: "Pagamentos sem título de mesmo valor", 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; if (!("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); } pidConcTooltipEl.textContent = alvo.dataset.concTooltip; pidConcTooltipEl.hidden = false; const margem = 8; const rect = alvo.getBoundingClientRect(); const largura = pidConcTooltipEl.offsetWidth; const altura = pidConcTooltipEl.offsetHeight; const esquerda = Math.max(margem, Math.min(rect.left, window.innerWidth - largura - margem)); let topo = rect.top - altura - margem; if (topo < margem) topo = rect.bottom + margem; pidConcTooltipEl.style.left = `${esquerda}px`; pidConcTooltipEl.style.top = `${topo}px`; } 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).", 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.", 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}.`; const resultado = diferenca === 0 ? "Fecha sem diferença." : diferenca > 0 ? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos${vinculo.tipo === "parcial" ? " (em aberto)" : ""}.` : `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 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); } /** 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). */ function pidConcSituacaoHtml(situacao) { if (!situacao) return ''; const sugestao = situacao.sugestao ? `Sugestão${pidConcEscape(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 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"; 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."; return; } if (!arquivo) { formErroEl.textContent = "Selecione o arquivo do razão."; return; } clearTimeout(timerNomeEmpresa); 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); processarBtn.disabled = true; processarBtn.textContent = "Processando..."; try { const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados }); formEl.hidden = true; await carregarHistorico(); abrirDetalhe(criada.id); } catch (erro) { formErroEl.textContent = erro.message; } finally { processarBtn.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 || {}; const conciliados = porStatus.conciliada || 0; 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")} ${pidConcData(item.data_base)} ${periodo} ${resumo.total_contas ?? "—"} ${conciliados} ${porStatus.divergente ? `${porStatus.divergente}` : 0} ${pidConcMoeda(resumo.total_em_aberto)} ${pidConcEscape(item.criado_por_nome || "—")}${pidConcDataHora(item.criado_em)} `; tr.addEventListener("click", (evento) => { if (evento.target.closest("[data-excluir]")) return; abrirDetalhe(item.id); }); tr.querySelector("[data-excluir]").addEventListener("click", async () => { const confirmado = await pidConfirm( `Excluir a conciliação de ${item.codigo_empresa} - ${item.nome_empresa || ""} (data-base ${pidConcData(item.data_base)})? Os vínculos manuais e validações feitos nela também serão excluídos.`, { titulo: "Excluir conciliação", textoConfirmar: "Excluir", perigoso: true } ); if (!confirmado) return; try { await pidApiRequest(`/conciliacoes-fornecedores/${item.id}/`, { method: "DELETE" }); await carregarHistorico(); } catch (erro) { pidAlert(erro.message, { titulo: "Não foi possível excluir" }); } }); listaBody.appendChild(tr); }); } listaBusca.addEventListener("input", renderHistorico); async function carregarHistorico() { try { historico = await pidApiRequest("/conciliacoes-fornecedores/"); } catch (erro) { historico = []; listaVazia.hidden = false; listaVazia.textContent = erro.message; return; } renderHistorico(); } // ====================================================================== // 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 filtroStatusBtns = Array.from(document.querySelectorAll("[data-conc-status]")); const filtroAlertaEl = document.getElementById("conc-filtro-alerta"); const sortHeaders = Array.from(document.querySelectorAll("#conc-contas-table th[data-sort]")); const mostrarValidadasInput = document.getElementById("conc-mostrar-validadas"); const mostrarValidadasQtd = document.getElementById("conc-mostrar-validadas-qtd"); let detalhe = null; let filtroStatus = ""; let filtroAlerta = ""; // Padrão: por situação, divergentes primeiro (pedido do usuário). const SORT_PADRAO = "status"; let sortKey = SORT_PADRAO; let sortDir = 1; const abertas = new Set(); // estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados const estadoConta = new Map(); function estadoDe(contaId) { if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false }); return estadoConta.get(contaId); } document.getElementById("conc-voltar-btn").addEventListener("click", () => { history.replaceState(null, "", window.location.pathname); mostrarLista(); }); function mostrarLista() { detalhe = null; detailView.hidden = true; listView.hidden = false; carregarHistorico(); } async function abrirDetalhe(id) { listView.hidden = true; detailView.hidden = false; cardsEl.innerHTML = ""; contasBody.innerHTML = 'Carregando...'; history.replaceState(null, "", `#conciliacao-${id}`); abertas.clear(); estadoConta.clear(); vencAbertas.clear(); vencFiltro = "pendentes"; vencBusca.value = ""; detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar mostrarAba("conciliacao"); filtroStatus = ""; filtroAlerta = ""; contasBusca.value = ""; mostrarValidadasInput.checked = false; sortKey = SORT_PADRAO; sortDir = 1; atualizarSortHeaders(); try { detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`); } catch (erro) { contasBody.innerHTML = `${pidConcEscape(erro.message)}`; return; } renderCabecalho(); renderCards(); renderContas(); } 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"; document.getElementById("conc-det-meta").innerHTML = ` Data-base ${pidConcData(detalhe.data_base)} Período do razão ${periodo} Arquivo ${pidConcEscape(detalhe.nome_arquivo || "—")} Processado por ${pidConcEscape(detalhe.criado_por_nome || "—")} em ${pidConcDataHora(detalhe.criado_em)}`; 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 porStatus = {}; contas.forEach((c) => { porStatus[c.status] = (porStatus[c.status] || 0) + 1; }); 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 excedentes = contas.filter((c) => Number(c.total_excedente) > 0); 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"], ["pagamento_sem_titulo", "com pagamento não vinculado", "warning"], ["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( "Títulos em aberto", pidConcMoeda(soma(contas, "total_em_aberto")), soma(contas, "total_nao_vinculado") ? `sem pagamento vinculado · ${pidConcMoeda(soma(contas, "total_nao_vinculado"))} em pagamentos a vincular` : "sem pagamento vinculado", "", "" )} ${kpi("Pagamentos sem título", pidConcMoeda(soma(excedentes, "total_excedente")), `${excedentes.length} fornecedor(es) · divergência`, "conc-kpi--danger", "debito_excedente")}
    Validação${validados} de ${contas.length} fornecedores
    ${contas.length - validados} ainda para revisar · ${pct}% concluído
    ${apontamentos ? `
    Apontamentos:${apontamentos}
    ` : ""}`; cardsEl.querySelectorAll("[data-conc-alerta]").forEach((el) => { el.addEventListener("click", () => { const alerta = el.dataset.concAlerta; filtroAlerta = filtroAlerta === alerta ? "" : alerta; filtroStatus = ""; mostrarAba("conciliacao"); renderCards(); renderContas(); }); }); cardsEl.querySelector("[data-conc-abrir-vencidos]").addEventListener("click", () => { mostrarAba(abaAtual === "vencidos" ? "conciliacao" : "vencidos"); }); abaVencidosQtd.textContent = vencidas.length - vencidasValidadas; filtroStatusBtns.forEach((btn) => { const status = btn.dataset.concStatus; const quantidade = status === "validada" ? validados : status ? porStatus[status] || 0 : contas.length; btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} ${quantidade}`; btn.classList.toggle("is-active", !filtroAlerta && filtroStatus === status); }); mostrarValidadasQtd.textContent = validados; } filtroStatusBtns.forEach((btn) => { btn.addEventListener("click", () => { filtroStatus = btn.dataset.concStatus; filtroAlerta = ""; renderCards(); renderContas(); }); }); contasBusca.addEventListener("input", renderContas); mostrarValidadasInput.addEventListener("change", renderContas); const sortValor = { conta_numero: (c) => Number(c.conta_numero), fornecedor: (c) => c.fornecedor, saldo_inicial: (c) => Number(c.saldo_inicial), saldo_final: (c) => Number(c.saldo_final), total_vencido: (c) => Number(c.total_vencido), status: (c) => PID_CONC_STATUS[c.status].rank, validada: (c) => (c.validada ? 1 : 0), }; 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(); // Validadas ficam fora por padrão (pedido do usuário): a lista mostra // o que ainda falta revisar. O chip "Validados" as mostra sempre. if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false; if (filtroAlerta) { const temAlerta = filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta); if (!temAlerta) return false; } else if (filtroStatus === "validada") { // validação é uma marcação à parte da situação calculada if (!c.validada) return false; } else if (filtroStatus && c.status !== filtroStatus) { return false; } return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo); } 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; } 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 = !mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim() ? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los." : "Nenhum fornecedor para este filtro."; const fragmento = document.createDocumentFragment(); lista.forEach((conta) => montarLinhasConta(conta).forEach((tr) => fragmento.appendChild(tr))); contasBody.appendChild(fragmento); } 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); const vencido = Number(conta.total_vencido); tr.innerHTML = ` ${PID_CONC_CHEVRON} ${pidConcEscape(conta.conta_numero)} ${pidConcEscape(conta.fornecedor)}${pidConcEscape(resumoAlertas(conta))} ${pidConcSaldo(conta.saldo_inicial)} ${pidConcSaldo(conta.saldo_final)} ${vencido ? pidConcMoeda(vencido) : "—"} ${pidConcStatusHtml(conta.status)} `; 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; 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" }); } }); tr.addEventListener("click", () => { if (abertas.has(conta.id)) abertas.delete(conta.id); else abertas.add(conta.id); substituirConta(conta); }); 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 = 8; 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 com "Mostrar validadas" // desmarcado, desmarcada no chip "Validados", mudou de situação com um // chip de situação ativo) 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 = !mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim() ? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los." : "Nenhum fornecedor para este filtro."; } }, 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)); const datasPendencia = conta.lancamentos .filter((l) => l.vinculo_id === null || idsParciais.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 ${razao.map((l) => linhaRazaoHtml(l)).join("")}`; secaoPend.appendChild(tabela); } else { secaoPend.insertAdjacentHTML("beforeend", '

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

    '); } painel.appendChild(secaoPend); function linhaRazaoHtml(lanc) { const comum = ` ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} ${pidConcHistoricoHtml(lanc)} ${lanc.natureza === "C" ? "Crédito" : "Débito"} ${pidConcMoeda(lanc.valor)} ${lanc.saldo_apos === null ? "—" : pidConcSaldo(lanc.saldo_apos)}`; if (lanc.vinculo_id === null) { const selecionado = estado.selecionados.has(lanc.id); return ` ${comum} ${pidConcValorAbertoHtml(lanc.situacao)} ${pidConcSituacaoHtml(lanc.situacao)} `; } const vinculo = vinculoPorId.get(lanc.vinculo_id); const origem = vinculo && (vinculo.tipo === "manual" || vinculo.tipo === "parcial") ? "manual" : "automático"; return ` ${comum} — Vinculado #${numeroVinculo.get(lanc.vinculo_id) || "?"} ${pidConcEscape(vinculo ? vinculo.tipo_rotulo : "")} · ${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("; "); const confirmado = await pidConfirm( `Desfazer o vínculo #${numeroVinculo.get(vinculoId) || ""} (${lista})? Os lançamentos voltam para não conciliados.`, { 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 não vinculadas // (com checkbox): o saldo de vínculo parcial fica só no destaque. 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])); function atualizarSelecao() { const selecionados = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean); 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); const temAmbos = selecionados.some((l) => l.natureza === "C") && selecionados.some((l) => l.natureza === "D"); vincularBtn.disabled = !temAmbos; painel.querySelectorAll("[data-adicionar]").forEach((btn) => { btn.disabled = selecionados.length === 0; }); if (todosCheck) { todosCheck.checked = selecionados.length > 0 && selecionados.length === pendentes.length; todosCheck.indeterminate = selecionados.length > 0 && selecionados.length < pendentes.length; todosCheck.title = selecionados.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, diferenca: Math.round((creditos - debitos) * 100) / 100 }; } atualizarSelecao(); // Cabeçalho: sem nada marcado, marca todos; com algum marcado (parcial ou // todos), desmarca todos. if (todosCheck) { todosCheck.addEventListener("change", () => { const marcar = estado.selecionados.size === 0; secaoPend.querySelectorAll("[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])").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 (String(window.getSelection ? window.getSelection() : "").trim()) 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(); }); }); vincularBtn.addEventListener("click", async () => { const { selecionados, diferenca } = atualizarSelecao(); // Mesmo critério do servidor (vincular/): créditos maiores que os // débitos além da tolerância = pagamento parcial, o título continua em // aberto pelo saldo. const totalCreditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0); const limite = Math.max(Number(detalhe.tolerancia_valor), (totalCreditos * Number(detalhe.tolerancia_percentual)) / 100); if (diferenca > limite) { const seguir = await pidConfirm( `Os débitos selecionados pagam ${pidConcMoeda(totalCreditos - diferenca)} de ${pidConcMoeda(totalCreditos)}. Vincular como pagamento parcial? O título continua em aberto com ${pidConcMoeda(diferenca)}.`, { titulo: "Pagamento parcial", textoConfirmar: "Vincular" } ); if (!seguir) return; } else if (diferenca !== 0) { const natureza = diferenca < 0 ? "pago a maior (possível juros/multa)" : "pago a menor (possível 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: "Vínculo com diferença", 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) }, }); 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", async () => { const novos = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean); if (!novos.length) return; 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 efeito = arredondada > 0 ? `restarão ${pidConcMoeda(arredondada)} de diferença (em aberto, se passar da tolerância)` : arredondada < 0 ? `ficará pago a maior em ${pidConcMoeda(-arredondada)} (possível juros/multa)` : "o vínculo fecha sem diferença"; const seguir = await pidConfirm( `Adicionar ${novos.length} lançamento(s) a este vínculo? Com eles, ${efeito}.`, { titulo: "Adicionar ao vínculo", textoConfirmar: "Adicionar" } ); if (!seguir) return; adicionarBtn.disabled = true; try { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculo.id}/adicionar/`, { method: "POST", body: { lancamentos: novos.map((l) => l.id) }, }); aplicarRespostaConta(resposta); } catch (erro) { adicionarBtn.disabled = false; pidAlert(erro.message, { titulo: "Não foi possível adicionar" }); } }); } 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]"); async function salvarValidacao(validada) { try { const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, { method: "POST", body: { validada, observacao: observacaoEl.value }, }); aplicarRespostaConta(resposta); } catch (erro) { 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 renderContas(); } abaBtns.forEach((btn) => btn.addEventListener("click", () => mostrarAba(btn.dataset.concAba))); 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", 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); } 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]"); const salvar = async (validada) => { try { await salvarVencidos(conta, validada, observacaoEl.value); } catch (erro) { 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, }); 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 = window.location.hash.match(/^#conciliacao-(\d+)$/); if (hash) { abrirDetalhe(Number(hash[1])); } else { carregarHistorico(); } });