/** * 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" }); const PID_CONC_STATUS = { divergente: { rotulo: "Divergente", pill: "status-pill--danger", rank: 0 }, atencao: { rotulo: "Atenção", pill: "status-pill--warning", rank: 1 }, pendente: { rotulo: "Pendente de pagamento", pill: "status-pill--neutral", rank: 2 }, conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 3 }, }; const PID_CONC_ROTULO_ALERTA = { credito_vencido: "Títulos em aberto há mais de 3 meses", 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" }; function pidConcEscape(texto) { if (texto == null) return ""; const div = document.createElement("div"); div.textContent = String(texto); return div.innerHTML; } function pidConcMoeda(valor) { return PID_CONC_MOEDA.format(Number(valor) || 0); } /** 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.pendente; 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(); 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 = contasComAlerta("credito_vencido"); 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("Em aberto há mais de 3 meses", pidConcMoeda(soma(vencidas, "total_vencido")), `${vencidas.length} fornecedor(es)`, "conc-kpi--warning", "credito_vencido")} ${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 = ""; renderCards(); renderContas(); }); }); 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); }); } function contasFiltradas() { const termo = contasBusca.value.trim().toLowerCase(); let lista = detalhe.contas.filter((c) => { // 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); }); 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) { if (!conta.alertas.length) return conta.qtd_pendentes ? `${conta.qtd_pendentes} lançamento(s) pendente(s)` : ""; // 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 = ` ${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(); substituirConta(resposta.conta); } function linhaLancamentoHtml(lanc, comCheckbox, selecionado) { const situacao = lanc.situacao; const valorAberto = pidConcValorAbertoHtml(situacao); const historico = lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(lanc.historico); return ` ${comCheckbox ? `` : ""} ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} ${historico}${lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""} ${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 historico = lanc.eh_saldo_anterior ? "Saldo anterior" : pidConcEscape(lanc.historico); const contrapartida = lanc.contrapartida ? `${pidConcEscape(lanc.contrapartida)}` : ""; const comum = ` ${pidConcData(lanc.data)} ${pidConcEscape(lanc.sequencia || "—")} ${historico}${contrapartida} ${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"))); }); // "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-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; 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; } // ====================================================================== // Início // ====================================================================== const hash = window.location.hash.match(/^#conciliacao-(\d+)$/); if (hash) { abrirDetalhe(Number(hash[1])); } else { carregarHistorico(); } });