portal_publico/static/js/conciliacao-fornecedores.js

2611 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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-<id>` na URL, para um
* recarregamento voltar à mesma conciliação.
*/
const PID_CONC_MOEDA = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" });
// "Pendente de pagamento" deixou de existir: só títulos em aberto, sem
// pagamento a conciliar, é conciliada (pedido do usuário).
const PID_CONC_STATUS = {
divergente: { rotulo: "Crítico", pill: "status-pill--danger", rank: 0 },
atencao: { rotulo: "A validar", pill: "status-pill--warning", rank: 1 },
// sem alerta, com saldo de pagamento parcial ainda a pagar
parcial: { rotulo: "Parcial", pill: "status-pill--parcial", rank: 2 },
conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 3 },
};
/** Vínculo de pagamento parcial com saldo ainda a pagar. */
function pidConcTemParcialAberto(conta) {
return conta.vinculos.some((v) => v.tipo === "parcial" && Number(v.diferenca) > 0);
}
// Chips da lista de fornecedores (pedido do usuário, rodada 65 do
// CHANGELOG): agrupam pela validação, não pela situação calculada.
// Parcial, Saldo em aberto e Conciliados dividem as validadas.
const PID_CONC_GRUPOS = {
pendentes: (c) => !c.validada,
parcial: (c) => c.validada && pidConcTemParcialAberto(c),
saldo_aberto: (c) => c.validada && !pidConcTemParcialAberto(c) && Number(c.saldo_final) !== 0,
conciliados: (c) => c.validada && !pidConcTemParcialAberto(c) && Number(c.saldo_final) === 0,
validados: (c) => c.validada,
};
// Ajuste de cada vínculo manual com diferença (pedido do usuário, rodada 74
// do CHANGELOG): parcelamento = pagamento parcial com saldo a pagar; juros =
// pago a maior; desconto = pago a menor, fechado. Derivado de tipo +
// diferença (créditos − débitos), nada gravado à parte; o contador escolhe
// entre parcelamento e desconto ao vincular (`fechar` no vincular/adicionar).
const PID_CONC_AJUSTES = {
parcelamento: {
rotulo: "Parcelamento",
icone: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 11h18"/></svg>',
},
juros: {
rotulo: "Juros",
icone: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
},
desconto: {
rotulo: "Desconto",
icone: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M19 12l-7 7-7-7"/></svg>',
},
};
function pidConcAjusteVinculo(vinculo) {
const diferenca = Number(vinculo.diferenca);
if (vinculo.tipo === "parcial") return diferenca > 0 ? "parcelamento" : null;
if (vinculo.tipo !== "manual" && vinculo.tipo !== "tolerancia") return null;
if (diferenca < 0) return "juros";
return diferenca > 0 ? "desconto" : null;
}
/** Valor por ajuste da conta ({parcelamento: 4327.1, ...}), só os presentes. */
function pidConcAjustesConta(conta) {
const total = {};
conta.vinculos.forEach((v) => {
const ajuste = pidConcAjusteVinculo(v);
if (ajuste) total[ajuste] = (total[ajuste] || 0) + Math.abs(Number(v.diferenca));
});
return total;
}
function pidConcAjustesHtml(conta) {
const ajustes = pidConcAjustesConta(conta);
const itens = Object.keys(PID_CONC_AJUSTES)
.filter((chave) => ajustes[chave])
.map((chave) => {
const sufixo = chave === "parcelamento" ? " a pagar" : "";
const dica = `${PID_CONC_AJUSTES[chave].rotulo}: ${pidConcMoeda(ajustes[chave])}${sufixo}`;
return `<span class="conc-ajuste conc-ajuste--${chave}" data-conc-tooltip="${pidConcEscapeAttr(dica)}" data-conc-tooltip-sempre>${PID_CONC_AJUSTES[chave].icone}</span>`;
});
return itens.length ? itens.join("") : '<span class="conc-valor--zero">—</span>';
}
// Escolha do contador quando os títulos passam dos pagamentos além da
// tolerância: parcelamento (o saldo continua a pagar) ou desconto (fecha).
// Modal no padrão do Portal, com três saídas (parcelamento, desconto,
// cancelar), por isso não usa `pidConfirm`. Devolve a chave ou null.
let pidConcEscolhaModal = null;
function pidConcEscolheAjuste(mensagem, diferenca) {
if (!pidConcEscolhaModal) {
pidConcEscolhaModal = document.createElement("div");
pidConcEscolhaModal.className = "modal-overlay modal-overlay--top";
pidConcEscolhaModal.hidden = true;
pidConcEscolhaModal.innerHTML = `
<div class="modal-card">
<h2 class="modal-card__title">Diferença no vínculo</h2>
<p class="modal-card__subtitle" data-escolha-mensagem></p>
<div class="conc-escolha">
<button type="button" class="conc-escolha__opcao" data-escolha="parcelamento">
<span class="conc-ajuste conc-ajuste--parcelamento">${PID_CONC_AJUSTES.parcelamento.icone}</span>
<span><strong>Parcelamento</strong><small data-escolha-parcelamento></small></span>
</button>
<button type="button" class="conc-escolha__opcao" data-escolha="desconto">
<span class="conc-ajuste conc-ajuste--desconto">${PID_CONC_AJUSTES.desconto.icone}</span>
<span><strong>Desconto</strong><small data-escolha-desconto></small></span>
</button>
</div>
<div class="modal-actions">
<button type="button" class="btn-outline" data-escolha="">Cancelar</button>
</div>
</div>`;
document.body.appendChild(pidConcEscolhaModal);
}
const modal = pidConcEscolhaModal;
modal.querySelector("[data-escolha-mensagem]").textContent = mensagem;
modal.querySelector("[data-escolha-parcelamento]").textContent = `O título continua em aberto com ${pidConcMoeda(diferenca)} a pagar nos próximos meses.`;
modal.querySelector("[data-escolha-desconto]").textContent = `O vínculo fecha; os ${pidConcMoeda(diferenca)} ficam registrados como desconto.`;
modal.hidden = false;
return new Promise((resolve) => {
function finalizar(resultado) {
modal.hidden = true;
modal.removeEventListener("click", onClick);
resolve(resultado || null);
}
function onClick(evento) {
if (evento.target === modal) return finalizar(null);
const botao = evento.target.closest("[data-escolha]");
if (botao) finalizar(botao.dataset.escolha);
}
modal.addEventListener("click", onClick);
});
}
const PID_CONC_CHEVRON =
'<span class="conc-expand-btn"><svg class="conc-expand-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>';
const PID_CONC_ROTULO_ALERTA = {
criticos_pendentes: "Críticos pendentes",
debito_excedente: "Pagamentos sem título em aberto",
pagamento_sem_titulo: "Pagamentos a vincular",
diferenca_vinculo: "Diferenças de juros/desconto",
conta_transitoria: "Conta transitória com saldo",
saldo_devedor: "Saldo devedor",
saldo_divergente_arquivo: "Saldo diferente do arquivo",
};
const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" };
// Delegado ao `pidEscapeHtml` (api.js), que já escapa aspas: serve para
// conteúdo e para atributo. `pidConcEscapeAttr` fica como sinônimo.
function pidConcEscape(texto) {
return pidEscapeHtml(texto);
}
function pidConcEscapeAttr(texto) {
return pidEscapeHtml(texto);
}
/** Célula de histórico em uma linha só (reticências + texto completo no
* hover); a contrapartida segue na segunda linha, também cortada. */
function pidConcHistoricoHtml(lanc) {
const texto = lanc.eh_saldo_anterior ? "Saldo anterior" : lanc.historico || "";
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(texto);
const contrapartida = lanc.contrapartida
? `<span class="conc-historico__contrapartida" data-conc-tooltip="${pidConcEscapeAttr(lanc.contrapartida)}">${pidConcEscape(lanc.contrapartida)}</span>`
: "";
return `<td class="conc-historico"><span class="conc-historico__texto" data-conc-tooltip="${pidConcEscapeAttr(texto)}">${historico}</span>${contrapartida}</td>`;
}
// Tooltip do texto cortado com reticências (`[data-conc-tooltip]`), no
// visual do Portal em vez do `title` nativo (pedido do usuário; mesmo
// visual de `.dc-hover-tooltip__texto` do Relatório Contábil). Um único
// elemento `position: fixed` no body (as tabelas cortam `absolute`),
// delegado no document para valer nas linhas recriadas a cada re-render.
// Só aparece quando o texto está de fato cortado, salvo com
// `data-conc-tooltip-sempre` (rótulo de botão só com ícone).
let pidConcTooltipEl = null;
function pidConcMostraTooltip(evento) {
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
if (!alvo) return;
// sugestão em várias linhas abre sempre: o balão mostra os lançamentos em
// colunas, que a célula (numa linha só) não mostra
const estruturada = alvo.classList.contains("conc-sugestao") && alvo.dataset.concTooltip.includes("\n");
if (!estruturada && !("concTooltipSempre" in alvo.dataset) && alvo.scrollWidth <= alvo.clientWidth) return;
if (!pidConcTooltipEl) {
pidConcTooltipEl = document.createElement("div");
pidConcTooltipEl.className = "conc-tooltip";
pidConcTooltipEl.setAttribute("role", "tooltip");
document.body.appendChild(pidConcTooltipEl);
}
// Variante "rica" (barra de validação): título com marcador de cor,
// valor em destaque e o texto do balão como detalhe, centrada no alvo
// com uma seta.
const titulo = alvo.dataset.concTooltipTitulo;
pidConcTooltipEl.classList.toggle("conc-tooltip--rico", Boolean(titulo));
if (titulo) {
pidConcTooltipEl.innerHTML = `
<span class="conc-tooltip__topo">
<span class="conc-tooltip__marcador conc-tooltip__marcador--${pidConcEscape(alvo.dataset.concTooltipCor || "")}"></span>
<span class="conc-tooltip__titulo">${pidConcEscape(titulo)}</span>
<strong class="conc-tooltip__valor">${pidConcEscape(alvo.dataset.concTooltipValor || "")}</strong>
</span>
<span class="conc-tooltip__detalhe">${pidConcEscape(alvo.dataset.concTooltip)}</span>`;
} else if (estruturada) {
// Os lançamentos citados são do lado oposto ao da linha: na sugestão de
// um título (ou do saldo a pagar), pagamentos; na de um pagamento, títulos.
const linha = alvo.closest("tr");
const naturezaItens = linha && linha.querySelector(".conc-natureza--D") ? "C" : "D";
pidConcTooltipEl.innerHTML = pidConcSugestaoEstruturadaHtml(alvo.dataset.concTooltip, naturezaItens);
} else {
pidConcTooltipEl.textContent = alvo.dataset.concTooltip;
}
pidConcTooltipEl.classList.toggle("conc-tooltip--sugestao", pidConcTooltipEl.querySelector(".conc-tooltip__lanc") !== null);
pidConcTooltipEl.hidden = false;
const margem = 8;
const rect = alvo.getBoundingClientRect();
const largura = pidConcTooltipEl.offsetWidth;
const altura = pidConcTooltipEl.offsetHeight;
const base = titulo ? rect.left + rect.width / 2 - largura / 2 : rect.left;
const esquerda = Math.max(margem, Math.min(base, window.innerWidth - largura - margem));
let topo = rect.top - altura - margem;
const abaixo = topo < margem;
if (abaixo) topo = rect.bottom + margem;
pidConcTooltipEl.classList.toggle("conc-tooltip--abaixo", abaixo);
// posição da seta: sempre no centro do alvo, mesmo se o balão encostou
// na borda da janela
pidConcTooltipEl.style.setProperty("--conc-tooltip-seta", `${rect.left + rect.width / 2 - esquerda}px`);
pidConcTooltipEl.style.left = `${esquerda}px`;
pidConcTooltipEl.style.top = `${topo}px`;
}
/** Sugestão em várias linhas no balão, mais visual (pedido do usuário): a
* 1ª linha é o título; cada linha de lançamento ("Seq. 37879 · 26/06/2026 ·
* R$ 2.163,57 · parcela 1 de 4", ver `_linha_item` em alertas.py) vira
* colunas; as demais são notas, e "Vincule para confirmar." fica apagada.
* O código leva a cor da natureza no razão (`naturezaItens` "C" ou "D";
* saldo a pagar em dourado), pedido do usuário. */
function pidConcSugestaoEstruturadaHtml(texto, naturezaItens) {
const linhas = texto.split("\n").map((l) => l.trim()).filter(Boolean);
return linhas
.map((linha, i) => {
if (i === 0) return `<div class="conc-tooltip__cabeca">${pidConcEscape(linha)}</div>`;
if (/^(Seq\. |Saldo a pagar · )/.test(linha)) {
const [codigo, data, valor, ...resto] = linha.split(" · ");
const natureza = codigo === "Saldo a pagar" ? "saldo" : naturezaItens;
return `<div class="conc-tooltip__lanc">
<span class="conc-tooltip__seq conc-natureza--${natureza}">${pidConcEscape(codigo)}</span>
<span>${pidConcEscape(data || "")}</span>
<span class="conc-tooltip__lanc-valor">${pidConcEscape(valor || "")}</span>
<span class="conc-tooltip__extra">${pidConcEscape(resto.join(" · "))}</span>
</div>`;
}
const apagada = linha === "Vincule para confirmar." ? " conc-tooltip__nota--apagada" : "";
return `<div class="conc-tooltip__nota${apagada}">${pidConcEscape(linha)}</div>`;
})
.join("");
}
// Posição do último "apertar" do mouse, para saber se um clique foi na
// verdade um arraste (seleção de texto para copiar). Captura, para valer
// mesmo que algum handler pare a propagação.
let pidConcMouseDown = null;
document.addEventListener("mousedown", (evento) => { pidConcMouseDown = { x: evento.clientX, y: evento.clientY }; }, true);
/** O clique terminou um arraste (mouse andou mais de 4 px desde o mousedown)? */
function pidConcFoiArraste(evento) {
if (!pidConcMouseDown) return false;
return Math.hypot(evento.clientX - pidConcMouseDown.x, evento.clientY - pidConcMouseDown.y) > 4;
}
function pidConcEscondeTooltip(evento) {
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
if (!alvo || !pidConcTooltipEl || alvo.contains(evento.relatedTarget)) return;
pidConcTooltipEl.hidden = true;
}
document.addEventListener("mouseover", pidConcMostraTooltip);
document.addEventListener("mouseout", pidConcEscondeTooltip);
window.addEventListener("scroll", () => { if (pidConcTooltipEl) pidConcTooltipEl.hidden = true; }, true);
function pidConcMoeda(valor) {
return PID_CONC_MOEDA.format(Number(valor) || 0);
}
const PID_CONC_ICONE_INFO =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-5M12 8h.01" stroke-linecap="round"/></svg>';
// ---- 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 `
<li class="conc-historico-vinc__lanc${lanc.acrescentado ? " is-acrescentado" : ""}">
<span class="conc-historico-vinc__seq">${seq}</span>
<span>${pidConcData(lanc.data)}</span>
<span><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></span>
<span class="conc-historico-vinc__texto">
<span class="conc-historico__texto" data-conc-tooltip="${pidConcEscapeAttr(lanc.historico || "")}">${lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico || "")}</span>
${lanc.contrapartida ? `<span class="conc-historico__contrapartida" data-conc-tooltip="${pidConcEscapeAttr(lanc.contrapartida)}">${pidConcEscape(lanc.contrapartida)}</span>` : ""}
</span>
<span class="conc-num">${pidConcMoeda(lanc.valor)}</span>
${lanc.acrescentado ? '<span class="conc-historico-vinc__novo">acrescentado</span>' : ""}
</li>`;
}
function pidConcHistoricoRegistroHtml(registro) {
const origem = registro.vinculo_automatico ? "automático" : "manual";
return `
<li class="conc-historico-vinc__item conc-historico-vinc__item--${registro.acao}">
<div class="conc-historico-vinc__cabecalho">
<strong>${pidConcEscape(registro.acao_rotulo)}</strong>
<span class="conc-historico-vinc__tipo">${pidConcEscape(registro.tipo_rotulo)} · ${origem}</span>
<span class="conc-historico-vinc__quem">${pidConcEscape(registro.usuario_nome || "—")} · ${pidConcDataHora(registro.criado_em)}</span>
</div>
<ul class="conc-historico-vinc__lancs">${registro.lancamentos.map(pidConcHistoricoLancamentoHtml).join("")}</ul>
</li>`;
}
/** 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 = `
<div class="modal-card modal-card--wide">
<h2 class="modal-card__title" data-historico-titulo></h2>
<p class="modal-card__subtitle" data-historico-subtitulo></p>
<div class="conc-historico-vinc" data-historico-corpo></div>
<div class="modal-actions">
<button type="button" class="btn-outline" data-historico-fechar>Fechar</button>
</div>
</div>`;
document.body.appendChild(pidConcHistoricoModal);
const fechar = () => { pidConcHistoricoModal.hidden = true; };
pidConcHistoricoModal.querySelector("[data-historico-fechar]").addEventListener("click", fechar);
pidConcHistoricoModal.addEventListener("click", (evento) => {
if (evento.target === pidConcHistoricoModal) fechar();
});
}
pidConcHistoricoModal.querySelector("[data-historico-titulo]").textContent = titulo;
pidConcHistoricoModal.querySelector("[data-historico-subtitulo]").textContent = subtitulo;
pidConcHistoricoModal.hidden = false;
return pidConcHistoricoModal.querySelector("[data-historico-corpo]");
}
// ---- Detalhe de um vínculo (botão "i" na linha vinculada) ----
// Pedido do usuário: ver o que o vínculo automático considerou sem caçar as
// outras linhas no razão (podem estar longe). Tudo já vem no payload da conta.
const PID_CONC_CRITERIO_VINCULO = {
exato: "Um crédito e um débito de mesmo valor. Havendo mais de um candidato, fica o de data mais próxima (e, no empate, o pagamento feito depois do título).",
combinacao: "Vários lançamentos cuja soma fecha exatamente (até 6 itens, dentro de 120 dias, entre os de data mais próxima). Critério usado só em importações até 29/09/2026: hoje esse caso fica pendente, como sugestão.",
sequencia: "O pagamento fecha exatamente com os títulos mais antigos que estavam em aberto, somados em sequência (com data até a do pagamento). Sem limite de itens, porque só existe uma sequência possível. Critério usado só em importações até 29/09/2026: hoje esse caso fica pendente, como sugestão.",
saldo_zerado: "Trecho do razão em que o saldo da conta voltou a zero: o que sobrou nele se compensa por definição, então vira um único vínculo.",
tolerancia: "Débito e crédito (ou dois itens) com uma diferença pequena, dentro da tolerância da conciliação (valor fixo ou percentual do título, o maior). Possível juros/desconto. Critério usado só em importações até 25/09/2026: hoje esse caso fica pendente, como sugestão.",
manual: "Vinculado manualmente.",
parcial: "Pagamento parcial vinculado manualmente: a diferença continua em aberto.",
};
function pidConcAbreDetalheVinculo(conta, vinculoId, numero) {
const vinculo = conta.vinculos.find((v) => v.id === vinculoId);
if (!vinculo) return;
const lancs = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId);
const creditos = lancs.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
const debitos = lancs.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
const diferenca = Math.round((creditos - debitos) * 100) / 100;
const automatico = vinculo.tipo !== "manual" && vinculo.tipo !== "parcial";
let criterio = PID_CONC_CRITERIO_VINCULO[vinculo.tipo] || "";
if (!automatico && vinculo.criado_por_nome) criterio = `${criterio} Por ${vinculo.criado_por_nome}.`;
// Pagamento parcial com saldo: o saldo a pagar vai em destaque (pedido do
// usuário), no lugar da frase de diferença.
const saldoParcial = vinculo.tipo === "parcial" && Number(vinculo.diferenca) > 0 ? Number(vinculo.diferenca) : 0;
const resultado = saldoParcial
? `<span class="conc-vinculo-detalhe__saldo">Saldo a pagar <strong>${pidConcMoeda(saldoParcial)}</strong></span>`
: `<span>${
diferenca === 0
? "Fecha sem diferença."
: diferenca > 0
? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos.`
: `Débitos ${pidConcMoeda(-diferenca)} maiores que os créditos.`
}</span>`;
const corpo = pidConcAbreModalInfo(`Vínculo #${numero || "?"}`, `${conta.conta_numero} - ${conta.fornecedor}`);
corpo.innerHTML = `
<div class="conc-historico-vinc__item conc-historico-vinc__item--vinculou">
<div class="conc-historico-vinc__cabecalho">
<strong>${pidConcEscape(vinculo.tipo_rotulo)}</strong>
<span class="conc-historico-vinc__tipo">${automatico ? "automático" : "manual"}</span>
</div>
<p class="conc-vinculo-detalhe__criterio">${pidConcEscape(criterio)}</p>
<ul class="conc-historico-vinc__lancs">${lancs.map(pidConcHistoricoLancamentoHtml).join("")}</ul>
<div class="conc-vinculo-detalhe__totais">
<span>Créditos <strong>${pidConcMoeda(creditos)}</strong></span>
<span>Débitos <strong>${pidConcMoeda(debitos)}</strong></span>
${resultado}
</div>
</div>`;
}
async function pidConcAbreHistoricoVinculos(conta) {
const corpo = pidConcAbreModalInfo("Histórico de vinculações", `${conta.conta_numero} - ${conta.fornecedor}`);
corpo.innerHTML = '<p class="conc-historico-vinc__vazio">Carregando…</p>';
try {
const registros = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/historico-vinculos/`);
corpo.innerHTML = registros.length
? `<ul class="conc-historico-vinc__lista">${registros.map(pidConcHistoricoRegistroHtml).join("")}</ul>`
: '<p class="conc-historico-vinc__vazio">Nenhuma vinculação ou desvinculação manual registrada nesta conta.</p>';
} catch (erro) {
corpo.innerHTML = `<p class="modal-error">${pidConcEscape(erro.message)}</p>`;
}
}
/** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */
function pidConcSaldo(valor) {
const numero = Number(valor) || 0;
if (numero === 0) return pidConcMoeda(0);
return `${pidConcMoeda(Math.abs(numero))} ${numero > 0 ? "C" : "D"}`;
}
function pidConcData(iso) {
if (!iso) return "—";
const [ano, mes, dia] = iso.slice(0, 10).split("-");
return `${dia}/${mes}/${ano}`;
}
function pidConcIsoLocal(data) {
const mes = String(data.getMonth() + 1).padStart(2, "0");
const dia = String(data.getDate()).padStart(2, "0");
return `${data.getFullYear()}-${mes}-${dia}`;
}
/** Sugestão do campo "Razão até": último dia do mês anterior (o normal é
* exportar o razão depois do fechamento do mês), nunca antes de `minimo`. */
function pidConcRazaoAtePadrao(minimo) {
const hoje = new Date();
const padrao = pidConcIsoLocal(new Date(hoje.getFullYear(), hoje.getMonth(), 0));
return minimo && minimo > padrao ? minimo : padrao;
}
/** Campo "Razão até" (`.conc-data-field`): texto digitável em dd/mm/aaaa
* sobre um `<input type="date">` escondido, que guarda o valor ISO e segue
* sendo o lido pelo resto da tela (`.value`/`.min`/`.max`). O campo nativo
* aceita digitar 31/09 mas só devolve `value` vazio, sem dizer o que foi
* digitado; aqui, dia além do fim do mês vira o último dia (pedido do
* usuário: 31/09/2026 → 30/09/2026), sem aviso (também pedido dele; o
* aviso abaixo do campo fica só para erro). O botão abre o calendário
* nativo. Devolve `definir(iso)` (preencher por código) e `validar()` (erro
* do texto digitado, "" se válido ou vazio). */
function pidConcCampoData(dataInput) {
const campo = dataInput.closest(".conc-data-field");
const textoInput = campo.querySelector("input[type='text']");
const avisoEl = campo.parentElement.querySelector(".conc-data-field__aviso");
const dois = (n) => String(n).padStart(2, "0");
function avisar(mensagem, erro) {
avisoEl.textContent = mensagem;
avisoEl.classList.toggle("is-erro", !!erro);
avisoEl.hidden = !mensagem;
}
function definir(iso) {
dataInput.value = iso || "";
textoInput.value = dataInput.value ? pidConcData(dataInput.value) : "";
avisar("");
}
/** Lê o texto; com `completo`, texto parcial conta como erro. */
function interpretar(completo) {
const digitos = textoInput.value.replace(/\D/g, "");
dataInput.value = "";
if (!digitos) {
avisar("");
return "";
}
if (digitos.length < 8) {
if (!completo) {
avisar("");
return "";
}
const erro = "Data incompleta: use dd/mm/aaaa.";
avisar(erro, true);
return erro;
}
const dia = Number(digitos.slice(0, 2));
const mes = Number(digitos.slice(2, 4));
const ano = Number(digitos.slice(4, 8));
if (dia < 1 || mes < 1 || mes > 12 || ano < 1900) {
const erro = "Data inválida: confira o dia e o mês (dd/mm/aaaa).";
avisar(erro, true);
return erro;
}
const ultimoDia = new Date(ano, mes, 0).getDate();
definir(`${ano}-${dois(mes)}-${dois(Math.min(dia, ultimoDia))}`);
return "";
}
textoInput.addEventListener("input", (evento) => {
// Máscara dd/mm/aaaa; apagando, não recoloca a barra.
if (!evento.inputType || !evento.inputType.startsWith("delete")) {
const d = textoInput.value.replace(/\D/g, "").slice(0, 8);
textoInput.value = [d.slice(0, 2), d.slice(2, 4), d.slice(4)].filter(Boolean).join("/");
}
interpretar(false);
});
textoInput.addEventListener("blur", () => interpretar(true));
dataInput.addEventListener("change", () => definir(dataInput.value));
campo.querySelector(".conc-data-field__btn").addEventListener("click", () => {
try {
dataInput.showPicker();
} catch (erro) {
textoInput.focus(); // navegador sem showPicker(): fica a digitação
}
});
return { definir, validar: () => interpretar(true), focar: () => textoInput.focus() };
}
/** Lê `#conciliacao-<id>[?filtros]` da URL: `{ id, params }` ou `null`. */
function pidConcLeHash() {
const partes = window.location.hash.match(/^#conciliacao-(\d+)(?:\?(.*))?$/);
if (!partes) return null;
return { id: Number(partes[1]), params: new URLSearchParams(partes[2] || "") };
}
/** Busca sem redesenhar a lista a cada tecla: roda `funcao` só depois de
* `ms` sem digitação (a lista de fornecedores tem centenas de linhas). */
function pidConcAtrasado(funcao, ms = 150) {
let timer = null;
return () => {
clearTimeout(timer);
timer = setTimeout(funcao, ms);
};
}
/** Barra de progresso de "Nova Conciliação"/"Atualizar razão" (mesmo padrão
* de `pidDcIniciarProgressoSimulado` do Relatório Contábil): o processamento
* é uma requisição síncrona, sem percentual real. Sobe rápido e desacelera
* perto de 90%; só `completar()`, quando a resposta volta, chega a 100%. */
function pidConcIniciarProgresso(progressEl) {
const fillEl = progressEl.querySelector(".conc-progress__fill");
let pct = 0;
fillEl.style.width = "0%";
progressEl.hidden = false;
const intervalId = setInterval(() => {
pct += (90 - pct) * 0.1;
fillEl.style.width = `${pct}%`;
}, 200);
return {
parar() {
clearInterval(intervalId);
progressEl.hidden = true;
},
async completar() {
clearInterval(intervalId);
fillEl.style.width = "100%";
await new Promise((resolve) => setTimeout(resolve, 300));
progressEl.hidden = true;
},
};
}
function pidConcDataHora(iso) {
if (!iso) return "—";
const data = new Date(iso);
if (Number.isNaN(data.getTime())) return "—";
return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function pidConcCompara(a, b) {
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a ?? "").localeCompare(String(b ?? ""), "pt-BR", { sensitivity: "base", numeric: true });
}
/** Valor em aberto de um item: sempre o real (só vínculo muda valor). */
function pidConcValorAbertoHtml(situacao) {
if (!situacao || !Number(situacao.valor_em_aberto)) return "—";
return pidConcMoeda(situacao.valor_em_aberto);
}
/** Sugestão escrita em várias linhas (uma por título, para o balão) numa
* linha só, para a célula cortada com reticências: "; " entre itens, espaço
* depois de linha terminada em ":" ou ".". Mesma regra de
* `texto_em_linha()` em `alertas.py` (XLSX). */
function pidConcTextoEmLinha(texto) {
const partes = String(texto || "").split("\n").map((p) => p.trim()).filter(Boolean);
return partes.reduce((acc, parte) => (acc ? acc + (/[:.]$/.test(acc) ? " " : "; ") + parte : parte), "");
}
/** Célula de situação: o fato primeiro; a dedução da ferramenta, se houver,
* numa linha à parte marcada como sugestão (nunca misturada ao fato). O
* balão mostra a sugestão com as quebras de linha (`white-space: pre-line`
* em `.conc-tooltip`). */
function pidConcSituacaoHtml(situacao) {
if (!situacao) return '<td class="conc-situacao"></td>';
const sugestao = situacao.sugestao
? `<span class="conc-sugestao" data-conc-tooltip="${pidConcEscapeAttr(situacao.sugestao)}" data-conc-relacionados="${(situacao.relacionados || []).join(",")}"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(pidConcTextoEmLinha(situacao.sugestao))}</span>`
: "";
return `<td class="conc-situacao conc-situacao--${situacao.categoria}">${pidConcEscape(situacao.descricao)}${sugestao}</td>`;
}
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 `<span class="status-pill ${info.pill}">${info.rotulo}</span>`;
}
document.addEventListener("DOMContentLoaded", async () => {
const contentEl = document.getElementById("conc-content");
if (!contentEl) return;
const me = await pidGetMe();
if (!me) return;
const utilitarios = (me.permissoes_efetivas && me.permissoes_efetivas["utilitarios"]) || {};
const podeUsar = !!(utilitarios.apps && utilitarios.apps["conciliacao-fornecedores"]);
document.getElementById("conc-no-access").hidden = podeUsar;
contentEl.hidden = !podeUsar;
if (!podeUsar) return;
const listView = document.getElementById("conc-list-view");
const detailView = document.getElementById("conc-detail-view");
// ======================================================================
// Histórico + nova conciliação
// ======================================================================
const formEl = document.getElementById("conc-form");
const novaBtn = document.getElementById("conc-nova-btn");
const codigoInput = document.getElementById("conc-form-codigo");
const empresaEl = document.getElementById("conc-form-empresa");
const arquivoInput = document.getElementById("conc-form-arquivo");
const arquivoNomeEl = document.getElementById("conc-form-arquivo-nome");
const periodoFimInput = document.getElementById("conc-form-periodo-fim");
const periodoFimCampo = pidConcCampoData(periodoFimInput);
const formErroEl = document.getElementById("conc-form-erro");
const processarBtn = document.getElementById("conc-form-processar-btn");
const listaBody = document.getElementById("conc-lista-body");
const listaVazia = document.getElementById("conc-lista-vazia");
const listaBusca = document.getElementById("conc-lista-busca");
let historico = [];
let nomeEmpresaResolvido = null; // null = não consultado/não encontrado
let timerNomeEmpresa = null;
let consultaNomeAtual = 0;
function limparFormulario() {
codigoInput.value = "";
empresaEl.textContent = "";
empresaEl.className = "conc-form__empresa";
arquivoInput.value = "";
arquivoNomeEl.textContent = "Nenhum arquivo selecionado";
periodoFimCampo.definir(""); // em branco: o contador informa (pedido do usuário)
periodoFimInput.max = pidConcIsoLocal(new Date());
formErroEl.textContent = "";
nomeEmpresaResolvido = null;
}
novaBtn.addEventListener("click", () => {
const abrir = formEl.hidden;
formEl.hidden = !abrir;
if (abrir) {
limparFormulario();
codigoInput.focus();
}
});
document.getElementById("conc-form-cancelar-btn").addEventListener("click", () => {
formEl.hidden = true;
});
arquivoInput.addEventListener("change", () => {
const arquivo = arquivoInput.files[0];
arquivoNomeEl.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
// O nome do arquivo costuma começar pelo código da empresa ("2017 - ..."):
// sugere, sem sobrescrever o que já foi digitado.
const prefixo = arquivo && arquivo.name.match(/^(\d+)\s*[-_ ]/);
if (prefixo && !codigoInput.value.trim()) {
codigoInput.value = prefixo[1];
buscarNomeEmpresa();
}
});
async function buscarNomeEmpresa() {
const codigo = codigoInput.value.trim();
nomeEmpresaResolvido = null;
if (!codigo) {
empresaEl.textContent = "";
empresaEl.className = "conc-form__empresa";
return;
}
if (!/^\d+$/.test(codigo)) {
empresaEl.textContent = "Informe apenas números.";
empresaEl.className = "conc-form__empresa is-erro";
return;
}
const consulta = ++consultaNomeAtual;
empresaEl.textContent = "Buscando no Questor…";
empresaEl.className = "conc-form__empresa is-carregando";
try {
const dados = await pidApiRequest(`/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=${encodeURIComponent(codigo)}`);
if (consulta !== consultaNomeAtual) return;
nomeEmpresaResolvido = dados.nome_empresa;
empresaEl.textContent = dados.nome_empresa || "Empresa não encontrada no Questor.";
empresaEl.className = `conc-form__empresa${dados.nome_empresa ? "" : " is-erro"}`;
} catch (erro) {
if (consulta !== consultaNomeAtual) return;
empresaEl.textContent = erro.message;
empresaEl.className = "conc-form__empresa is-erro";
}
}
codigoInput.addEventListener("input", () => {
clearTimeout(timerNomeEmpresa);
timerNomeEmpresa = setTimeout(buscarNomeEmpresa, 450);
});
processarBtn.addEventListener("click", async () => {
formErroEl.textContent = "";
const codigo = codigoInput.value.trim();
const arquivo = arquivoInput.files[0];
if (!codigo) {
formErroEl.textContent = "Informe o código da empresa.";
codigoInput.focus();
return;
}
if (!arquivo) {
formErroEl.textContent = "Selecione o arquivo do razão.";
return;
}
const erroPeriodoFim = periodoFimCampo.validar();
if (erroPeriodoFim || !periodoFimInput.value) {
formErroEl.textContent = erroPeriodoFim
? `"Razão até": ${erroPeriodoFim}`
: "Informe até quando o razão foi exportado (\"Razão até\").";
periodoFimCampo.focar();
return;
}
clearTimeout(timerNomeEmpresa);
// Uma conciliação por empresa: o razão novo vai para a existente.
const codigoCanonico = codigo.replace(/^0+(?=\d)/, ""); // mesmo critério de normalizar_codigo_empresa()
const existente = historico.find((item) => String(item.codigo_empresa) === codigoCanonico);
if (existente) {
const atualizar = await pidConfirm(
`A empresa ${codigo} já tem uma conciliação (${existente.periodo_inicio ? `${pidConcData(existente.periodo_inicio)} a ${pidConcData(existente.periodo_fim)}` : "sem lançamentos"}). O razão novo substitui o dela. Atualizar essa conciliação com este arquivo?`,
{ titulo: "Empresa já conciliada", textoConfirmar: "Atualizar a existente" }
);
if (!atualizar) return;
formEl.hidden = true;
abrirAtualizar(existente, arquivo, periodoFimInput.value);
return;
}
if (nomeEmpresaResolvido === null) await buscarNomeEmpresa();
if (!nomeEmpresaResolvido) {
const seguir = await pidConfirm(
`A empresa ${codigo} não foi encontrada no Questor (ou o Questor está inacessível agora). Processar assim mesmo?`,
{ titulo: "Empresa não encontrada", textoConfirmar: "Processar" }
);
if (!seguir) return;
}
const dados = new FormData();
dados.append("codigo_empresa", codigo);
dados.append("arquivo", arquivo);
dados.append("periodo_fim", periodoFimInput.value);
const cancelarBtn = document.getElementById("conc-form-cancelar-btn");
processarBtn.disabled = true;
cancelarBtn.disabled = true;
processarBtn.textContent = "Processando…";
const progresso = pidConcIniciarProgresso(document.getElementById("conc-form-progress"));
try {
const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados });
await progresso.completar();
formEl.hidden = true;
await carregarHistorico();
abrirDetalhe(criada.id, { empilhar: true });
} catch (erro) {
progresso.parar();
formErroEl.textContent = erro.message;
} finally {
processarBtn.disabled = false;
cancelarBtn.disabled = false;
processarBtn.textContent = "Processar";
}
});
function renderHistorico() {
const termo = listaBusca.value.trim().toLowerCase();
const itens = historico.filter(
(item) => !termo || `${item.codigo_empresa} ${item.nome_empresa}`.toLowerCase().includes(termo)
);
listaBody.innerHTML = "";
listaVazia.hidden = itens.length > 0;
listaVazia.textContent = historico.length ? "Nenhuma conciliação para esta busca." : "Nenhuma conciliação realizada ainda.";
itens.forEach((item) => {
const resumo = item.resumo || {};
const porStatus = resumo.por_status || {};
// Sem atualização do razão, a última atualização é a própria criação.
const ultima = item.ultima_atualizacao;
const ultimaPor = ultima ? ultima.usuario_nome : item.criado_por_nome;
const ultimaEm = ultima ? ultima.criado_em : item.criado_em;
const periodo = item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "—";
const tr = document.createElement("tr");
tr.className = "conc-row";
tr.innerHTML = `
<td><span class="pa-name-link">${pidConcEscape(item.codigo_empresa)} - ${pidConcEscape(item.nome_empresa || "Empresa não encontrada no Questor")}</span></td>
<td>${periodo}</td>
<td class="conc-num">${resumo.total_contas ?? "—"}</td>
<td class="conc-num">${porStatus.divergente ? `<span class="conc-valor--devedor">${porStatus.divergente}</span>` : 0}</td>
<td class="conc-num conc-valor">${pidConcMoeda(resumo.total_em_aberto)}</td>
<td>${pidConcEscape(ultimaPor || "—")}<span class="conc-fornecedor__alerta">${pidConcDataHora(ultimaEm)}</span></td>
<td class="conc-num">
<span class="conc-lista-acoes">
<button type="button" class="btn-outline conc-btn-sm" data-atualizar>Atualizar</button>
</span>
</td>`;
tr.addEventListener("click", (evento) => {
if (evento.target.closest("[data-atualizar]")) return;
abrirDetalhe(item.id, { empilhar: true });
});
tr.querySelector("[data-atualizar]").addEventListener("click", () => abrirAtualizar(item));
listaBody.appendChild(tr);
});
}
listaBusca.addEventListener("input", pidConcAtrasado(renderHistorico));
async function carregarHistorico() {
try {
historico = await pidApiRequest("/conciliacoes-fornecedores/");
} catch (erro) {
historico = [];
listaVazia.hidden = false;
listaVazia.textContent = erro.message;
return;
}
renderHistorico();
}
// ======================================================================
// Atualizar o razão (arquivo novo substitui o da conciliação)
// ======================================================================
const atualizarModal = document.getElementById("conc-atualizar-modal");
const atualizarArquivoInput = document.getElementById("conc-atualizar-arquivo");
const atualizarArquivoNome = document.getElementById("conc-atualizar-arquivo-nome");
const atualizarPeriodoFim = document.getElementById("conc-atualizar-periodo-fim");
const atualizarPeriodoFimCampo = pidConcCampoData(atualizarPeriodoFim);
const atualizarErro = document.getElementById("conc-atualizar-erro");
const atualizarBtn = document.getElementById("conc-atualizar-processar-btn");
let atualizarAlvo = null;
function periodoTexto(item) {
return item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "sem lançamentos";
}
/** `arquivoInicial`/`periodoFimInicial`: arquivo e "Razão até" já escolhidos
* em "Nova Conciliação" para uma empresa que já tinha conciliação. */
function abrirAtualizar(item, arquivoInicial, periodoFimInicial) {
atualizarAlvo = item;
document.getElementById("conc-atualizar-empresa").textContent =
`${item.codigo_empresa} - ${item.nome_empresa || "Empresa não encontrada no Questor"}`;
document.getElementById("conc-atualizar-periodo").innerHTML =
`Período atual do razão: <strong>${periodoTexto(item)}</strong>`;
atualizarArquivoInput.value = "";
if (arquivoInicial) {
const transferencia = new DataTransfer();
transferencia.items.add(arquivoInicial);
atualizarArquivoInput.files = transferencia.files;
}
atualizarArquivoNome.textContent = arquivoInicial ? arquivoInicial.name : "Nenhum arquivo selecionado";
// O razão novo não pode terminar antes do atual.
atualizarPeriodoFim.min = item.periodo_fim || "";
atualizarPeriodoFim.max = pidConcIsoLocal(new Date());
atualizarPeriodoFimCampo.definir(
periodoFimInicial && (!item.periodo_fim || periodoFimInicial >= item.periodo_fim)
? periodoFimInicial
: pidConcRazaoAtePadrao(item.periodo_fim)
);
atualizarErro.textContent = "";
atualizarModal.hidden = false;
}
function fecharAtualizar() {
if (atualizarBtn.disabled) return; // processando
atualizarModal.hidden = true;
atualizarAlvo = null;
}
atualizarArquivoInput.addEventListener("change", () => {
const arquivo = atualizarArquivoInput.files[0];
atualizarArquivoNome.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
});
document.getElementById("conc-atualizar-cancelar-btn").addEventListener("click", fecharAtualizar);
atualizarModal.addEventListener("click", (evento) => {
if (evento.target === atualizarModal) fecharAtualizar();
});
document.getElementById("conc-det-atualizar-btn").addEventListener("click", () => {
if (detalhe) abrirAtualizar(detalhe);
});
function resumoAtualizacaoTexto(r) {
const n = (chave) => r[chave] || 0;
const linhas = [
n("acrescimo")
? "Período novo acrescentado: saldos iniciais conferidos com a conciliação, e o que já existia foi mantido."
: "Razão substituído pelo arquivo novo.",
`Período do razão: ${periodoTexto(atualizarAlvo)} → ${r.periodo_novo}.`,
n("lancamentos_novos") || n("lancamentos_removidos")
? `Lançamentos: ${n("lancamentos_mantidos")} mantidos, ${n("lancamentos_novos")} novos, ${n("lancamentos_removidos")} fora do arquivo novo.`
: `Lançamentos: nenhum lançamento novo no período (${n("lancamentos_mantidos")} mantidos).`,
`Vínculos: ${n("vinculos_mantidos")} mantidos, ${n("vinculos_desfeitos")} desfeitos (${n("vinculos_manuais_desfeitos")} manuais), ${n("vinculos_automaticos_novos")} automáticos novos.`,
];
if (n("contas_novas") || n("contas_removidas")) {
linhas.push(`Fornecedores: ${n("contas_novas")} novos, ${n("contas_removidas")} fora do arquivo novo.`);
}
if (n("contas_sem_movimento")) {
linhas.push(`Fornecedores sem movimento no período novo (mantidos como estavam): ${n("contas_sem_movimento")}.`);
}
if (n("validacoes_retiradas") || n("revisoes_vencidos_retiradas")) {
linhas.push(
`A validar de novo: ${n("validacoes_retiradas")} conta(s) e ${n("revisoes_vencidos_retiradas")} revisão(ões) de títulos antigos.`
);
}
return linhas.join("\n");
}
atualizarBtn.addEventListener("click", async () => {
atualizarErro.textContent = "";
const arquivo = atualizarArquivoInput.files[0];
if (!arquivo) {
atualizarErro.textContent = "Selecione o arquivo do razão atualizado.";
return;
}
const erroPeriodoFim = atualizarPeriodoFimCampo.validar();
if (erroPeriodoFim || !atualizarPeriodoFim.value) {
atualizarErro.textContent = erroPeriodoFim
? `"Razão até": ${erroPeriodoFim}`
: "Informe até quando o razão foi exportado (\"Razão até\").";
atualizarPeriodoFimCampo.focar();
return;
}
const alvo = atualizarAlvo;
const dados = new FormData();
dados.append("arquivo", arquivo);
dados.append("periodo_fim", atualizarPeriodoFim.value);
const cancelarBtn = document.getElementById("conc-atualizar-cancelar-btn");
atualizarBtn.disabled = true;
cancelarBtn.disabled = true;
atualizarBtn.textContent = "Atualizando…";
const progresso = pidConcIniciarProgresso(document.getElementById("conc-atualizar-progress"));
let resposta;
try {
resposta = await pidApiRequest(`/conciliacoes-fornecedores/${alvo.id}/atualizar/`, { method: "POST", body: dados });
await progresso.completar();
} catch (erro) {
progresso.parar();
atualizarErro.textContent = erro.message;
return;
} finally {
atualizarBtn.disabled = false;
cancelarBtn.disabled = false;
atualizarBtn.textContent = "Atualizar";
}
fecharAtualizar();
atualizarAlvo = alvo; // para o texto do período anterior
const texto = resumoAtualizacaoTexto({ ...resposta.atualizacao, periodo_novo: periodoTexto(resposta.conciliacao) });
atualizarAlvo = null;
if (!detailView.hidden && detalhe && detalhe.id === alvo.id) {
const atual = pidConcLeHash();
await abrirDetalhe(alvo.id, { params: atual && atual.params });
} else {
await carregarHistorico();
}
pidAlert(texto, { titulo: "Razão atualizado" });
});
// ======================================================================
// Detalhe
// ======================================================================
const cardsEl = document.getElementById("conc-cards");
const contasBody = document.getElementById("conc-contas-body");
const contasVazia = document.getElementById("conc-contas-vazia");
const contasBusca = document.getElementById("conc-contas-busca");
const filtroGrupoBtns = Array.from(document.querySelectorAll("[data-conc-grupo]"));
const contasTable = document.getElementById("conc-contas-table");
// Filtro por ajuste (parcelamento/juros/desconto): só nos chips de validadas.
let filtroAjuste = "";
const filtroAlertaEl = document.getElementById("conc-filtro-alerta");
const sortHeaders = Array.from(document.querySelectorAll("#conc-contas-table th[data-sort]"));
let detalhe = null;
const GRUPO_PADRAO = "pendentes";
let filtroGrupo = GRUPO_PADRAO;
let filtroAlerta = "";
// Ordem padrão de cada chip: nos pendentes, por situação, críticos
// primeiro; nos de validadas, da validação mais recente para a mais
// antiga (pedidos do usuário).
function sortPadrao() {
return filtroGrupo === "pendentes" ? { key: "status", dir: 1 } : { key: "validada_em", dir: -1 };
}
let sortKey = "status";
let sortDir = 1;
function restauraSortPadrao() {
({ key: sortKey, dir: sortDir } = sortPadrao());
}
const abertas = new Set();
// estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados
const estadoConta = new Map();
// Observações digitadas e ainda não salvas, por conta (pedido do usuário:
// o texto não pode sumir quando a conta é redesenhada). Sobrevivem ao
// recolher/expandir e à troca de filtro; ao sair da conciliação com
// rascunho pendente, a tela pergunta antes.
const rascunhos = new Map(); // observação da conciliação
const rascunhosVenc = new Map(); // observação dos títulos antigos
function estadoDe(contaId) {
if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false });
return estadoConta.get(contaId);
}
function temRascunhoPendente() {
if (!detalhe) return false;
return detalhe.contas.some(
(c) =>
(rascunhos.has(c.id) && rascunhos.get(c.id) !== (c.observacao || "")) ||
(rascunhosVenc.has(c.id) && rascunhosVenc.get(c.id) !== (c.vencidos_observacao || ""))
);
}
/** Pergunta antes de descartar observações não salvas; `true` = pode sair. */
async function podeSairDoDetalhe() {
if (!temRascunhoPendente()) return true;
const sair = await pidConfirm(
"Há observações digitadas e não salvas nesta conciliação. Sair sem salvar as alterações?",
{ titulo: "Sair sem salvar", textoConfirmar: "Sair sem salvar", perigoso: true }
);
if (sair) {
rascunhos.clear();
rascunhosVenc.clear();
}
return sair;
}
window.addEventListener("beforeunload", (evento) => {
if (temRascunhoPendente()) evento.preventDefault();
});
// ---- Estado na URL ----
// `#conciliacao-<id>` seguido dos filtros, da aba e da ordenação
// (`?aba=vencidos&grupo=validados&ordem=-saldo_final`), para recarregar
// a página voltar ao mesmo ponto. Abrir uma conciliação empilha uma
// entrada no histórico do navegador, então o Voltar dele volta à lista;
// trocar filtro só substitui a entrada atual.
function hashDetalhe() {
const params = new URLSearchParams();
if (abaAtual !== "conciliacao") params.set("aba", abaAtual);
if (filtroGrupo !== GRUPO_PADRAO) params.set("grupo", filtroGrupo);
if (filtroAjuste) params.set("ajuste", filtroAjuste);
if (filtroAlerta) params.set("alerta", filtroAlerta);
const padrao = sortPadrao();
if (sortKey !== padrao.key || sortDir !== padrao.dir) params.set("ordem", `${sortDir === -1 ? "-" : ""}${sortKey}`);
if (vencFiltro !== "pendentes") params.set("vfiltro", vencFiltro || "todos");
if (vencSortKey !== "total_vencido" || vencSortDir !== -1) params.set("vordem", `${vencSortDir === -1 ? "-" : ""}${vencSortKey}`);
const consulta = params.toString();
return `#conciliacao-${detalhe.id}${consulta ? `?${consulta}` : ""}`;
}
function atualizarUrl() {
if (!detalhe || detailView.hidden) return;
const hash = hashDetalhe();
if (window.location.hash !== hash) history.replaceState(history.state, "", hash);
}
/** Aplica os parâmetros lidos da URL (valores desconhecidos são ignorados). */
function aplicarParametros(params) {
if (!params) return;
const grupo = params.get("grupo") || "";
if (PID_CONC_GRUPOS[grupo]) filtroGrupo = grupo;
const ajuste = params.get("ajuste") || "";
if (PID_CONC_AJUSTES[ajuste] && filtroGrupo !== "pendentes") filtroAjuste = ajuste;
restauraSortPadrao();
const alerta = params.get("alerta") || "";
if (PID_CONC_ROTULO_ALERTA[alerta]) filtroAlerta = alerta;
const ordem = params.get("ordem") || "";
if (sortValor[ordem.replace(/^-/, "")]) {
sortKey = ordem.replace(/^-/, "");
sortDir = ordem.startsWith("-") ? -1 : 1;
}
const vfiltro = params.get("vfiltro");
if (vfiltro === "validados" || vfiltro === "todos") vencFiltro = vfiltro === "todos" ? "" : vfiltro;
const vordem = params.get("vordem") || "";
if (vencSortValor[vordem.replace(/^-/, "")]) {
vencSortKey = vordem.replace(/^-/, "");
vencSortDir = vordem.startsWith("-") ? -1 : 1;
}
}
document.getElementById("conc-voltar-btn").addEventListener("click", async () => {
if (!(await podeSairDoDetalhe())) return;
// Entrou pela lista: volta a entrada do histórico (o `popstate` mostra a
// lista). Entrou direto pela URL: não há para onde voltar, então troca.
if (history.state && history.state.concDetalhe) {
history.back();
} else {
history.replaceState(null, "", window.location.pathname);
mostrarLista();
}
});
window.addEventListener("popstate", async () => {
const alvo = pidConcLeHash();
if (alvo && detalhe && alvo.id === detalhe.id && !detailView.hidden) return;
if (!detailView.hidden && !(await podeSairDoDetalhe())) {
// cancelou: devolve a entrada da conciliação aberta
history.pushState({ concDetalhe: true }, "", hashDetalhe());
return;
}
if (alvo) abrirDetalhe(alvo.id, { params: alvo.params });
else mostrarLista();
});
function mostrarLista() {
detalhe = null;
rascunhos.clear();
rascunhosVenc.clear();
detailView.hidden = true;
listView.hidden = false;
carregarHistorico();
}
/** `empilhar`: abrir a partir da lista (cria entrada no histórico do
* navegador). `params`: filtros/aba/ordem lidos da URL. Reabrir a mesma
* conciliação (depois de "Atualizar razão") mantém os rascunhos. */
async function abrirDetalhe(id, { empilhar = false, params = null } = {}) {
const mesma = detalhe && detalhe.id === id;
listView.hidden = true;
detailView.hidden = false;
cardsEl.innerHTML = "";
contasBody.innerHTML = '<tr><td colspan="7" class="conc-empty">Carregando…</td></tr>';
if (empilhar) history.pushState({ concDetalhe: true }, "", `#conciliacao-${id}`);
else if (!pidConcLeHash() || pidConcLeHash().id !== id) history.replaceState(history.state, "", `#conciliacao-${id}`);
if (!mesma) {
rascunhos.clear();
rascunhosVenc.clear();
}
abertas.clear();
estadoConta.clear();
vencAbertas.clear();
vencFiltro = "pendentes";
vencSortKey = "total_vencido";
vencSortDir = -1;
vencBusca.value = "";
detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar
mostrarAba("conciliacao");
filtroGrupo = GRUPO_PADRAO;
filtroAjuste = "";
filtroAlerta = "";
contasBusca.value = "";
restauraSortPadrao();
aplicarParametros(params);
atualizarSortHeaders();
try {
detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`);
} catch (erro) {
contasBody.innerHTML = `<tr><td colspan="7" class="conc-empty">${pidConcEscape(erro.message)}</td></tr>`;
return;
}
renderCabecalho();
const abaUrl = params && params.get("aba");
mostrarAba(abaUrl === "vencidos" || abaUrl === "relatorio" ? abaUrl : "conciliacao");
}
function renderCabecalho() {
document.getElementById("conc-det-codigo").textContent = `EMPRESA ${detalhe.codigo_empresa}`;
document.getElementById("conc-det-nome").textContent = detalhe.nome_empresa || "Empresa não encontrada no Questor";
const periodo = detalhe.periodo_inicio ? `${pidConcData(detalhe.periodo_inicio)} a ${pidConcData(detalhe.periodo_fim)}` : "sem lançamentos";
// Sem atualização do razão, a última atualização é a própria criação
// (mesma regra da coluna da lista).
const ultima = detalhe.ultima_atualizacao;
const ultimaPor = ultima ? ultima.usuario_nome : detalhe.criado_por_nome;
const ultimaEm = ultima ? ultima.criado_em : detalhe.criado_em;
document.getElementById("conc-det-meta").innerHTML = `
<span>Período do razão <strong>${periodo}</strong></span>
<span>Arquivo <strong>${pidConcEscape(detalhe.nome_arquivo || "—")}</strong></span>
<span>Última atualização <strong>${pidConcEscape(ultimaPor || "—")}</strong> em ${pidConcDataHora(ultimaEm)}</span>`;
document.getElementById("conc-exportar-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/exportar-xlsx/`;
}
function contasComAlerta(tipo) {
return detalhe.contas.filter((c) => c.alertas.some((a) => a.tipo === tipo));
}
/** Faixa de indicadores (3 valores + progresso da validação) e etiquetas
* dos apontamentos menores. Os números por situação ficam só nos chips,
* que já filtram (não duplicar em card). */
function renderCards() {
const contas = detalhe.contas;
const soma = (lista, campo) => lista.reduce((total, c) => total + (Number(c[campo]) || 0), 0);
const vencidas = contasVencidas();
const vencidasValidadas = vencidas.filter((c) => c.vencidos_validados).length;
const aVincular = contas.filter((c) => Number(c.total_nao_vinculado) > 0);
const emAberto = contas.filter((c) => Number(c.total_em_aberto) > 0);
const criticasPendentes = contas.filter((c) => c.status === "divergente" && !c.validada).length;
const validados = contas.filter((c) => c.validada).length;
const pct = contas.length ? Math.round((validados / contas.length) * 100) : 0;
const kpi = (rotulo, valor, detalheTxt, classe, alerta) => {
const tag = alerta ? "button" : "div";
const ativo = alerta && filtroAlerta === alerta ? " is-active" : "";
const atributos = alerta ? ` type="button" data-conc-alerta="${alerta}"` : "";
return `<${tag} class="conc-kpi ${classe}${ativo}"${atributos}>
<span class="conc-kpi__rotulo">${rotulo}</span>
<span class="conc-kpi__valor">${valor}</span>
<span class="conc-kpi__detalhe">${detalheTxt}</span>
</${tag}>`;
};
const apontamentos = [
["diferenca_vinculo", "com diferença de juros/desconto", "info"],
["conta_transitoria", "conta transitória com saldo", "danger"],
]
.map(([tipo, texto, classe]) => [tipo, contasComAlerta(tipo).length, texto, classe])
.filter(([, quantidade]) => quantidade > 0)
.map(
([tipo, quantidade, texto, classe]) =>
`<button type="button" class="conc-tag conc-tag--${classe}${filtroAlerta === tipo ? " is-active" : ""}" data-conc-alerta="${tipo}"><strong>${quantidade}</strong> ${texto}</button>`
)
.join("");
cardsEl.innerHTML = `
<div class="conc-kpis">
${kpi(
"Pagamentos a vincular",
pidConcMoeda(soma(aVincular, "total_nao_vinculado")),
`${aVincular.length} fornecedor(es)`,
"conc-kpi--warning",
"pagamento_sem_titulo"
)}
${kpi(
"Títulos em aberto",
pidConcMoeda(soma(emAberto, "total_em_aberto")),
`${emAberto.length} fornecedor(es) · disponíveis para vincular os pagamentos`,
"",
""
)}
${kpi("Críticos pendentes", `${criticasPendentes} fornecedor(es)`, "em situação Crítico, sem validação", "conc-kpi--danger", "criticos_pendentes")}
<div class="conc-progresso">
<div class="conc-progresso__topo"><span class="conc-kpi__rotulo">Validação</span><span><strong>${validados}</strong> de ${contas.length} fornecedores</span></div>
<div class="conc-progresso__barra">
${validados ? `<span class="conc-progresso__feito" style="width:${pct}%" data-conc-tooltip="${validados} de ${contas.length} fornecedores validados" data-conc-tooltip-titulo="Concluído" data-conc-tooltip-valor="${pct}%" data-conc-tooltip-cor="ok" data-conc-tooltip-sempre></span>` : ""}
${contas.length - validados ? `<span class="conc-progresso__falta" data-conc-tooltip="${contas.length - validados} de ${contas.length} fornecedores ainda para revisar" data-conc-tooltip-titulo="Pendente" data-conc-tooltip-valor="${100 - pct}%" data-conc-tooltip-cor="falta" data-conc-tooltip-sempre></span>` : ""}
</div>
</div>
</div>
${apontamentos ? `<div class="conc-tags"><span class="conc-tags__rotulo">Apontamentos:</span>${apontamentos}</div>` : ""}`;
// Filtro de card/etiqueta vale sobre todas as contas, validadas ou não,
// para a lista bater com o número mostrado; os chips ficam inativos.
cardsEl.querySelectorAll("[data-conc-alerta]").forEach((el) => {
el.addEventListener("click", () => {
const alerta = el.dataset.concAlerta;
filtroAlerta = filtroAlerta === alerta ? "" : alerta;
mostrarAba("conciliacao");
renderCards();
renderContas();
});
});
abaVencidosQtd.textContent = vencidas.length - vencidasValidadas;
filtroGrupoBtns.forEach((btn) => {
const grupo = btn.dataset.concGrupo;
const quantidade = contas.filter(PID_CONC_GRUPOS[grupo]).length;
btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} <span class="conc-chip__count">${quantidade}</span>`;
btn.classList.toggle("is-active", !filtroAlerta && filtroGrupo === grupo);
});
// Coluna "Ajuste" só nos chips de validadas (pedido do usuário); o
// filtro fica no cabeçalho dela, com o ícone do ajuste filtrado.
const mostraAjuste = filtroGrupo !== "pendentes" && !filtroAlerta;
contasTable.classList.toggle("conc-table--sem-ajuste", !mostraAjuste);
ajusteThBtn.classList.toggle("is-active", Boolean(filtroAjuste));
ajusteThAtivo.innerHTML = filtroAjuste
? `<span class="conc-ajuste conc-ajuste--${filtroAjuste}">${PID_CONC_AJUSTES[filtroAjuste].icone}</span>`
: "";
ajusteThBtn.setAttribute("aria-label", filtroAjuste ? `Ajuste: filtrando por ${PID_CONC_AJUSTES[filtroAjuste].rotulo}` : "Filtrar por ajuste");
}
// ---- Menu do filtro de ajuste, no cabeçalho da coluna ----
// Fixo no body (como o balão), porque o cabeçalho é sticky e a tabela
// cortaria um menu absoluto. Fecha ao escolher, ao clicar fora, com Esc
// e ao rolar.
const ajusteThBtn = document.getElementById("conc-ajuste-th");
const ajusteThAtivo = document.getElementById("conc-ajuste-th-ativo");
let ajusteMenu = null;
function fecharMenuAjuste() {
if (ajusteMenu) ajusteMenu.hidden = true;
ajusteThBtn.setAttribute("aria-expanded", "false");
}
function abrirMenuAjuste() {
if (!ajusteMenu) {
ajusteMenu = document.createElement("div");
ajusteMenu.className = "conc-ajuste-menu";
ajusteMenu.setAttribute("role", "menu");
ajusteMenu.hidden = true;
document.body.appendChild(ajusteMenu);
ajusteMenu.addEventListener("click", (evento) => {
const opcao = evento.target.closest("[data-conc-ajuste]");
if (!opcao) return;
filtroAjuste = opcao.dataset.concAjuste;
fecharMenuAjuste();
renderCards();
renderContas();
});
}
const doGrupo = detalhe.contas.filter(PID_CONC_GRUPOS[filtroGrupo]);
const opcoes = [["", "Todos", doGrupo.length]].concat(
Object.keys(PID_CONC_AJUSTES).map((chave) => [chave, PID_CONC_AJUSTES[chave].rotulo, doGrupo.filter((c) => pidConcAjustesConta(c)[chave]).length])
);
ajusteMenu.innerHTML = opcoes
.map(
([chave, rotulo, quantidade]) => `
<button type="button" role="menuitemradio" aria-checked="${filtroAjuste === chave}" class="conc-ajuste-menu__opcao${filtroAjuste === chave ? " is-active" : ""}" data-conc-ajuste="${chave}">
${chave ? `<span class="conc-ajuste conc-ajuste--${chave}">${PID_CONC_AJUSTES[chave].icone}</span>` : '<span class="conc-ajuste conc-ajuste--todos"></span>'}
<span class="conc-ajuste-menu__rotulo">${rotulo}</span>
<span class="conc-chip__count">${quantidade}</span>
</button>`
)
.join("");
ajusteMenu.hidden = false;
const rect = ajusteThBtn.getBoundingClientRect();
const largura = ajusteMenu.offsetWidth;
ajusteMenu.style.left = `${Math.max(8, Math.min(rect.left + rect.width / 2 - largura / 2, window.innerWidth - largura - 8))}px`;
ajusteMenu.style.top = `${rect.bottom + 6}px`;
ajusteThBtn.setAttribute("aria-expanded", "true");
}
ajusteThBtn.addEventListener("click", (evento) => {
// não ordena nem conta como "clicar fora" para recolher
evento.stopPropagation();
if (ajusteMenu && !ajusteMenu.hidden) fecharMenuAjuste();
else if (detalhe) abrirMenuAjuste();
});
document.addEventListener("click", (evento) => {
if (ajusteMenu && !ajusteMenu.hidden && !ajusteMenu.contains(evento.target)) fecharMenuAjuste();
});
document.addEventListener("keydown", (evento) => {
if (evento.key === "Escape") fecharMenuAjuste();
});
window.addEventListener("scroll", fecharMenuAjuste, true);
filtroGrupoBtns.forEach((btn) => {
btn.addEventListener("click", () => {
filtroGrupo = btn.dataset.concGrupo;
filtroAjuste = "";
filtroAlerta = "";
restauraSortPadrao();
atualizarSortHeaders();
renderCards();
renderContas();
});
});
contasBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderContas()));
const sortValor = {
conta_numero: (c) => Number(c.conta_numero),
fornecedor: (c) => c.fornecedor,
saldo_final: (c) => Number(c.saldo_final),
status: (c) => PID_CONC_STATUS[c.status].rank,
validada_em: (c) => (c.validada && c.validada_em) || "",
};
sortHeaders.forEach((th) => {
th.addEventListener("click", () => {
const chave = th.dataset.sort;
sortDir = sortKey === chave ? sortDir * -1 : 1;
sortKey = chave;
atualizarSortHeaders();
renderContas();
});
});
function atualizarSortHeaders() {
sortHeaders.forEach((h) => {
h.classList.toggle("is-sort-asc", h.dataset.sort === sortKey && sortDir === 1);
h.classList.toggle("is-sort-desc", h.dataset.sort === sortKey && sortDir === -1);
});
}
/** A conta aparece com o filtro atual? Usado pela lista inteira e depois
* de cada ajuste numa conta (`aplicarRespostaConta`). */
function contaPassaFiltro(c) {
const termo = contasBusca.value.trim().toLowerCase();
if (filtroAlerta) {
let temAlerta;
if (filtroAlerta === "criticos_pendentes") temAlerta = c.status === "divergente" && !c.validada;
else if (filtroAlerta === "debito_excedente") temAlerta = Number(c.total_excedente) > 0;
else temAlerta = c.alertas.some((a) => a.tipo === filtroAlerta);
if (!temAlerta) return false;
} else if (!PID_CONC_GRUPOS[filtroGrupo](c)) {
return false;
} else if (filtroAjuste && !pidConcAjustesConta(c)[filtroAjuste]) {
return false;
}
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
}
/** Tolerância da conciliação para um total de títulos (o maior entre o
* valor fixo e o percentual), mesma regra do servidor. */
function limiteTolerancia(totalCreditos) {
return Math.max(Number(detalhe.tolerancia_valor), (totalCreditos * Number(detalhe.tolerancia_percentual)) / 100);
}
const ROTULO_GRUPO = {
pendentes: "Pendentes",
parcial: "Parcial",
saldo_aberto: "Saldo em aberto",
conciliados: "Conciliados",
validados: "Validados",
};
/** Validar/desvalidar tira a conta da lista atual quando ela muda de chip
* (pedido do usuário: ela "sumia" sem aviso). Nesse caso pergunta antes,
* dizendo para qual chip ela vai; se continua na lista, não pergunta.
* Validar não muda saldo nem vínculos, então o destino é previsível. */
async function confirmaMudancaDeChip(conta, validada) {
const depois = { ...conta, validada };
if (contaPassaFiltro(depois)) return true;
const destino = Object.keys(PID_CONC_GRUPOS).find((g) => g !== "validados" && PID_CONC_GRUPOS[g](depois));
const nome = `${conta.conta_numero} - ${conta.fornecedor}`;
const mensagem = validada
? `"${nome}" será marcada como validada e sairá desta lista. Ela passa a aparecer em "${ROTULO_GRUPO[destino]}" (e em "Validados").`
: `A validação de "${nome}" será removida e ela sairá desta lista. Ela volta para "${ROTULO_GRUPO[destino]}".`;
return pidConfirm(mensagem, {
titulo: validada ? "Validar fornecedor" : "Remover validação",
textoConfirmar: validada ? "Validar" : "Remover validação",
});
}
function contasFiltradas() {
let lista = detalhe.contas.filter(contaPassaFiltro);
if (sortKey) {
const extrator = sortValor[sortKey];
lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir);
}
return lista;
}
// Clicar fora dos fornecedores expandidos recolhe todos (pedido do
// usuário). "Fora" = fora das linhas da tabela de fornecedores (a linha e
// a expansão dela). Não conta: clique em modal ou balão (abertos a partir
// da própria expansão), clique em elemento que a ação do clique já tirou
// da página (re-render depois de vincular, desfazer...) e clique que
// termina uma seleção de texto (para copiar). Os rascunhos de observação
// sobrevivem ao recolher.
document.addEventListener("click", (evento) => {
if (!detalhe || detailView.hidden || abaAtual !== "conciliacao" || !abertas.size) return;
const alvo = evento.target;
if (!(alvo instanceof Element) || !document.contains(alvo)) return;
if (contasBody.contains(alvo) || alvo.closest(".modal-overlay, .conc-tooltip")) return;
if (pidConcFoiArraste(evento)) return;
abertas.clear();
renderContas();
});
function textoListaVazia() {
return filtroGrupo === "pendentes" && !filtroAlerta && !contasBusca.value.trim()
? "Todos os fornecedores estão validados."
: "Nenhum fornecedor para este filtro.";
}
function renderContas() {
filtroAlertaEl.hidden = !filtroAlerta;
if (filtroAlerta) {
filtroAlertaEl.innerHTML = `Filtrando por: <strong>${PID_CONC_ROTULO_ALERTA[filtroAlerta]}</strong><button type="button">Limpar filtro</button>`;
filtroAlertaEl.querySelector("button").addEventListener("click", () => {
filtroAlerta = "";
renderCards();
renderContas();
});
}
const lista = contasFiltradas();
contasBody.innerHTML = "";
contasVazia.hidden = lista.length > 0;
contasVazia.textContent = textoListaVazia();
const fragmento = document.createDocumentFragment();
lista.forEach((conta) => montarLinhasConta(conta).forEach((tr) => fragmento.appendChild(tr)));
contasBody.appendChild(fragmento);
atualizarUrl();
}
function resumoAlertas(conta) {
// sem alerta, o que sobra pendente são só títulos (pagamento não vinculado é alerta)
if (!conta.alertas.length) return conta.qtd_pendentes ? `${conta.qtd_pendentes} título(s) em aberto, sem pagamento a conciliar` : "";
// só o primeiro alerta, cortado com reticências: o resto está na expansão
return conta.alertas[0].mensagem;
}
function montarLinhasConta(conta) {
const aberta = abertas.has(conta.id);
const tr = document.createElement("tr");
tr.className = `conc-row${aberta ? " is-aberta" : ""}`;
tr.dataset.contaId = conta.id;
const saldoFinal = Number(conta.saldo_final);
tr.innerHTML = `
<td>${PID_CONC_CHEVRON}</td>
<td class="conc-conta-numero">${pidConcEscape(conta.conta_numero)}</td>
<td><span class="conc-fornecedor__nome">${pidConcEscape(conta.fornecedor)}</span><span class="conc-fornecedor__alerta">${pidConcEscape(resumoAlertas(conta))}</span></td>
<td class="conc-num conc-valor${saldoFinal === 0 ? " conc-valor--zero" : saldoFinal < 0 ? " conc-valor--devedor" : ""}">${pidConcSaldo(conta.saldo_final)}</td>
<td class="conc-ajuste-col">${pidConcAjustesHtml(conta)}</td>
<td>${pidConcStatusHtml(conta.status)}</td>
<td class="conc-validado-col"><input type="checkbox" class="conc-validado-check" data-validado-check ${conta.validada ? "checked" : ""} aria-label="Confirmar que a conta foi validada" data-conc-tooltip="${pidConcEscapeAttr(pidConcTextoValidacao(conta) || "Marcar como validada")}" data-conc-tooltip-sempre />${
conta.validada && conta.validada_em ? `<span class="conc-validado-data">${pidConcDataHora(conta.validada_em)}</span>` : ""
}</td>`;
const check = tr.querySelector("[data-validado-check]");
// o clique no checkbox não expande/recolhe a linha
check.addEventListener("click", (evento) => evento.stopPropagation());
check.addEventListener("change", async () => {
check.disabled = true;
if (!(await confirmaMudancaDeChip(conta, check.checked))) {
check.checked = !check.checked;
check.disabled = false;
return;
}
try {
// sem `observacao` no corpo: o servidor mantém a já registrada
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, {
method: "POST",
body: { validada: check.checked },
});
aplicarRespostaConta(resposta);
} catch (erro) {
check.checked = !check.checked;
check.disabled = false;
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
}
});
// Copiar texto da linha (pedido do usuário): arrastar para selecionar
// não expande, e o duplo/triplo clique (selecionar palavra/linha)
// também não; por isso o clique simples espera um instante antes de
// expandir, para saber se vem um segundo clique.
// O critério é o arraste, não haver texto selecionado: clicar em cima de
// uma seleção que já existia ainda a vê no momento do clique (o
// navegador só a desfaz depois), e o clique era ignorado.
let esperaClique = null;
tr.addEventListener("click", (evento) => {
if (evento.detail > 1) {
clearTimeout(esperaClique);
return;
}
if (pidConcFoiArraste(evento)) return;
esperaClique = setTimeout(() => {
if (abertas.has(conta.id)) abertas.delete(conta.id);
else abertas.add(conta.id);
substituirConta(conta);
}, 200);
});
if (!aberta) return [tr];
const trDetalhe = document.createElement("tr");
trDetalhe.className = "conc-detail-row";
trDetalhe.dataset.contaDetalheId = conta.id;
const td = document.createElement("td");
td.colSpan = 7;
td.className = "conc-detail-cell";
td.appendChild(montarDetalheConta(conta));
trDetalhe.appendChild(td);
return [tr, trDetalhe];
}
/** Re-renderiza só as linhas de uma conta (a principal e o detalhe), no lugar. */
function substituirConta(conta) {
const antiga = contasBody.querySelector(`tr[data-conta-id="${conta.id}"]`);
if (!antiga) return;
const detalheAntigo = contasBody.querySelector(`tr[data-conta-detalhe-id="${conta.id}"]`);
const novas = montarLinhasConta(conta);
antiga.replaceWith(...novas);
if (detalheAntigo) detalheAntigo.remove();
}
function aplicarRespostaConta(resposta) {
const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id);
const eraValidada = indice >= 0 && detalhe.contas[indice].validada;
if (indice >= 0) detalhe.contas[indice] = resposta.conta;
// Conta que acabou de ser validada (checkbox, botão ou validação
// automática ao ficar conciliada) recolhe a expansão: o trabalho nela
// terminou (pedido do usuário).
if (!eraValidada && resposta.conta.validada) abertas.delete(resposta.conta.id);
detalhe.resumo = resposta.resumo;
const estado = estadoDe(resposta.conta.id);
estado.selecionados.clear();
renderCards();
// Conta que deixou de caber no filtro (validada no chip "Pendentes",
// desmarcada num chip de validadas, mudou de grupo) sai da lista na hora. Antes ela só era
// redesenhada no lugar e saía no próximo render da lista inteira
// (pedido do usuário: "tem um atraso, às vezes preciso atualizar").
if (contaPassaFiltro(resposta.conta)) substituirConta(resposta.conta);
else removerConta(resposta.conta.id);
}
/** Tira as linhas da conta da lista com um fade curto. */
function removerConta(contaId) {
const linhas = contasBody.querySelectorAll(`tr[data-conta-id="${contaId}"], tr[data-conta-detalhe-id="${contaId}"]`);
linhas.forEach((tr) => tr.classList.add("is-saindo"));
window.setTimeout(() => {
linhas.forEach((tr) => tr.remove());
if (!contasBody.querySelector("tr[data-conta-id]")) {
contasVazia.hidden = false;
contasVazia.textContent = textoListaVazia();
}
}, 160);
}
function linhaLancamentoHtml(lanc, comCheckbox, selecionado) {
const situacao = lanc.situacao;
const valorAberto = pidConcValorAbertoHtml(situacao);
return `
<tr class="${selecionado ? "is-selecionado" : ""}" data-lanc-id="${lanc.id}">
${comCheckbox ? `<td class="conc-subtabela__check"><input type="checkbox" data-lanc-check="${lanc.id}" ${selecionado ? "checked" : ""} aria-label="Selecionar lançamento" /></td>` : ""}
<td>${pidConcData(lanc.data)}</td>
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
${pidConcHistoricoHtml(lanc)}
<td><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></td>
<td class="conc-num conc-valor">${pidConcMoeda(lanc.valor)}</td>
${comCheckbox ? `<td class="conc-num conc-valor">${valorAberto}</td>${pidConcSituacaoHtml(situacao)}` : ""}
</tr>`;
}
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) =>
`<li class="conc-alerta conc-alerta--${a.severidade}"><span class="conc-alerta__severidade">${PID_CONC_SEVERIDADE[a.severidade]}</span><span>${pidConcEscape(a.mensagem)}</span></li>`
)
.join("");
painel.appendChild(lista);
}
// ---- Razão desde a primeira pendência ----
// Pedido do usuário: mostrar o razão inteiro a partir do lançamento
// pendente mais antigo (inclusive o que já foi vinculado nesse período),
// para enxergar vínculos automáticos errados e desfazê-los ali mesmo.
// "Pendente" inclui os itens de um pagamento parcial, cujo título segue
// em aberto.
const pendentes = conta.lancamentos.filter((l) => l.vinculo_id === null);
const numeroVinculo = new Map(conta.vinculos.map((v, i) => [v.id, i + 1]));
const vinculoPorId = new Map(conta.vinculos.map((v) => [v.id, v]));
const idsParciais = new Set(conta.vinculos.filter((v) => v.tipo === "parcial").map((v) => v.id));
// Proporção créditos:débitos de cada vínculo ("4:1" = 4 créditos para 1
// débito), no lugar do nome do tipo (pedido do usuário).
const proporcaoVinculo = new Map();
conta.lancamentos.forEach((l) => {
if (!l.vinculo_id) return;
const p = proporcaoVinculo.get(l.vinculo_id) || { c: 0, d: 0 };
if (l.natureza === "C") p.c += 1;
else p.d += 1;
proporcaoVinculo.set(l.vinculo_id, p);
});
// Vínculos manuais fechados com diferença (desconto ou juros): aparecem
// no razão como o parcelamento, com uma linha própria da diferença
// (pedido do usuário, rodada 76 do CHANGELOG).
const ajusteFechado = new Map(
conta.vinculos
.filter((v) => v.tipo === "manual")
.map((v) => [v.id, pidConcAjusteVinculo(v)])
.filter(([, ajuste]) => ajuste === "desconto" || ajuste === "juros")
);
const datasPendencia = conta.lancamentos
.filter((l) => l.vinculo_id === null || idsParciais.has(l.vinculo_id) || ajusteFechado.has(l.vinculo_id))
.map((l) => l.data);
const inicioPendencia = datasPendencia.length ? datasPendencia.reduce((a, b) => (a < b ? a : b)) : null;
const razao = inicioPendencia ? conta.lancamentos.filter((l) => l.data >= inicioPendencia) : [];
const qtdVinculadosRazao = razao.filter((l) => l.vinculo_id !== null).length;
const secaoPend = document.createElement("div");
secaoPend.innerHTML = `
<div class="conc-detail__secao-titulo conc-selecao-barra">
<div class="conc-detail__titulo-grupo">
<h4>Razão desde ${inicioPendencia ? pidConcData(inicioPendencia) : "—"} · ${pendentes.length} não conciliado(s)</h4>
${qtdVinculadosRazao ? `<label class="conc-toggle"><input type="checkbox" data-ocultar-conciliados ${estado.ocultarConciliados ? "checked" : ""} /> Ocultar conciliados <span class="conc-chip__count">${qtdVinculadosRazao}</span></label>` : ""}
</div>
<div class="conc-detail__acoes">
<span class="conc-selecao-resumo" data-selecao-resumo></span>
<button type="button" class="btn-solid conc-btn-sm" data-vincular disabled>Vincular selecionados</button>
<button type="button" class="conc-info-btn" data-historico-vinculos aria-label="Histórico de vinculações" data-conc-tooltip="Histórico de vinculações" data-conc-tooltip-sempre>${PID_CONC_ICONE_INFO}</button>
</div>
</div>`;
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 = `
<thead><tr><th class="conc-subtabela__check"><input type="checkbox" data-lanc-todos aria-label="Marcar ou desmarcar todos os não conciliados" data-conc-tooltip="Marcar todos" data-conc-tooltip-sempre /></th><th>Data</th><th>Seq.</th><th>Histórico</th><th>Natureza</th><th class="conc-num">Valor</th><th class="conc-num">Saldo</th><th class="conc-num">Em aberto</th><th>Situação</th></tr></thead>
<tbody>${linhasRazaoHtml()}</tbody>`;
secaoPend.appendChild(tabela);
} else {
secaoPend.insertAdjacentHTML("beforeend", '<p class="conc-sem-pendencias">Todos os lançamentos desta conta estão vinculados.</p>');
}
painel.appendChild(secaoPend);
// Linha temporária do saldo a pagar de um pagamento parcial (pedido do
// usuário): logo abaixo do último crédito (título) do vínculo no razão,
// com a diferença que continua em aberto. Sem crédito no razão, abaixo do
// último lançamento do vínculo. Não é lançamento do arquivo (sem seleção
// nem saldo); some sozinha quando o vínculo fecha ou é desfeito.
function linhasRazaoHtml() {
const ultimoDoParcial = new Map();
const temCredito = new Set();
razao.forEach((l) => {
if (!idsParciais.has(l.vinculo_id) && !ajusteFechado.has(l.vinculo_id)) return;
if (l.natureza === "C") {
ultimoDoParcial.set(l.vinculo_id, l.id);
temCredito.add(l.vinculo_id);
} else if (!temCredito.has(l.vinculo_id)) {
ultimoDoParcial.set(l.vinculo_id, l.id);
}
});
return razao
.map((l) => {
const linha = linhaRazaoHtml(l);
if (ultimoDoParcial.get(l.vinculo_id) !== l.id) return linha;
const vinculo = vinculoPorId.get(l.vinculo_id);
return linha + (ajusteFechado.has(vinculo.id) ? linhaAjusteHtml(vinculo, ajusteFechado.get(vinculo.id)) : linhaSaldoParcialHtml(vinculo));
})
.join("");
}
// Linha da diferença de um vínculo fechado com desconto (títulos maiores
// que os pagamentos) ou juros (pagamentos maiores), no mesmo lugar e
// formato da linha do saldo a pagar do parcelamento, mas sem seleção e
// sem valor em aberto: o vínculo está fechado.
function linhaAjusteHtml(vinculo, ajuste) {
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
const creditos = itens.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
const debitos = itens.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
const numero = numeroVinculo.get(vinculo.id) || "?";
const rotulo = PID_CONC_AJUSTES[ajuste].rotulo;
const contrapartida = `Pagos ${pidConcMoeda(debitos)} de ${pidConcMoeda(creditos)} em títulos`;
const situacao = ajuste === "desconto" ? "Vínculo fechado com desconto (pago a menor)." : "Vínculo fechado com juros/multa (pago a maior).";
return `
<tr class="conc-linha--ajuste conc-linha--ajuste-${ajuste}" data-vinculo-id="${vinculo.id}">
<td class="conc-subtabela__check"></td>
<td>—</td>
<td>—</td>
${pidConcHistoricoHtml({ historico: `${rotulo} do vínculo #${numero}`, contrapartida })}
<td><span class="conc-natureza conc-natureza--${ajuste}">${rotulo}</span></td>
<td class="conc-num conc-valor">${pidConcMoeda(Math.abs(Number(vinculo.diferenca)))}</td>
<td class="conc-num conc-valor conc-valor--saldo">—</td>
<td class="conc-num conc-valor">—</td>
<td class="conc-situacao">${situacao}</td>
</tr>`;
}
function linhaSaldoParcialHtml(vinculo) {
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
const creditos = itens.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
const debitos = itens.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
const numero = numeroVinculo.get(vinculo.id) || "?";
const situacao = vinculo.situacao || { categoria: "em_aberto", valor_em_aberto: vinculo.diferenca, descricao: "Saldo do pagamento parcial", sugestao: "" };
// Selecionável (pedido do usuário): com o saldo marcado, a barra
// acrescenta os demais selecionados a este vínculo em vez de criar outro.
// Id negativo (-id do vínculo), o mesmo das sugestões.
const selecionado = estado.selecionados.has(-vinculo.id);
return `
<tr class="conc-linha--saldo-parcial${selecionado ? " is-selecionado" : ""}" data-vinculo-id="${vinculo.id}" data-saldo-vinculo="${vinculo.id}">
<td class="conc-subtabela__check"><input type="checkbox" data-lanc-check="${-vinculo.id}" data-saldo-check ${selecionado ? "checked" : ""} aria-label="Selecionar o saldo a pagar do vínculo #${numero}" /></td>
<td>—</td>
<td>—</td>
${pidConcHistoricoHtml({ historico: `Saldo a pagar do vínculo #${numero}`, contrapartida: `Pagos ${pidConcMoeda(debitos)} de ${pidConcMoeda(creditos)} em títulos` })}
<td><span class="conc-natureza conc-natureza--saldo">A pagar</span></td>
<td class="conc-num conc-valor">${pidConcMoeda(vinculo.diferenca)}</td>
<td class="conc-num conc-valor conc-valor--saldo">—</td>
<td class="conc-num conc-valor">${pidConcValorAbertoHtml(situacao)}</td>
${pidConcSituacaoHtml(situacao)}
</tr>`;
}
function linhaRazaoHtml(lanc) {
// Saldo do Questor só onde ainda há saldo (pedido do usuário): na
// linha de vínculo fechado vira "—"; fica no lançamento pendente e no
// vínculo parcial com saldo a pagar.
const vinculoDaLinha = lanc.vinculo_id === null ? null : vinculoPorId.get(lanc.vinculo_id);
const mostraSaldo =
lanc.saldo_apos !== null &&
(!vinculoDaLinha || (vinculoDaLinha.tipo === "parcial" && Number(vinculoDaLinha.diferenca) > 0));
const comum = `
<td>${pidConcData(lanc.data)}</td>
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
${pidConcHistoricoHtml(lanc)}
<td><span class="conc-natureza conc-natureza--${lanc.natureza}">${lanc.natureza === "C" ? "Crédito" : "Débito"}</span></td>
<td class="conc-num conc-valor">${pidConcMoeda(lanc.valor)}</td>
<td class="conc-num conc-valor conc-valor--saldo">${mostraSaldo ? pidConcSaldo(lanc.saldo_apos) : "—"}</td>`;
if (lanc.vinculo_id === null) {
const selecionado = estado.selecionados.has(lanc.id);
// "Em aberto" do razão é só o saldo de pagamento parcial (linha
// própria, `linhaSaldoParcialHtml`), pedido do usuário: o lançamento
// sem vínculo não repete aqui o próprio valor.
return `
<tr class="${selecionado ? "is-selecionado" : ""}" data-lanc-id="${lanc.id}">
<td class="conc-subtabela__check"><input type="checkbox" data-lanc-check="${lanc.id}" ${selecionado ? "checked" : ""} aria-label="Selecionar lançamento" /></td>
${comum}
<td class="conc-num conc-valor">—</td>
${pidConcSituacaoHtml(lanc.situacao)}
</tr>`;
}
const vinculo = vinculoPorId.get(lanc.vinculo_id);
const origem = vinculo && (vinculo.tipo === "manual" || vinculo.tipo === "parcial") ? "manual" : "automático";
const proporcao = proporcaoVinculo.get(lanc.vinculo_id) || { c: 0, d: 0 };
const dicaTipo = `${vinculo ? vinculo.tipo_rotulo : "Vínculo"}: ${proporcao.c} crédito(s) para ${proporcao.d} débito(s)`;
return `
<tr class="conc-linha--vinculada" data-lanc-id="${lanc.id}" data-vinculo-id="${lanc.vinculo_id}">
<td class="conc-subtabela__check"></td>
${comum}
<td class="conc-num conc-valor">—</td>
<td class="conc-situacao">
<span class="conc-vinculado">
<span class="conc-vinculado-tag">#${numeroVinculo.get(lanc.vinculo_id) || "?"}</span>
<span class="conc-vinculado__tipo" data-conc-tooltip="${pidConcEscapeAttr(dicaTipo)}" data-conc-tooltip-sempre>${proporcao.c}:${proporcao.d} · ${origem}</span>
<button type="button" class="conc-link-btn" data-desfazer-vinculo="${lanc.vinculo_id}">Desfazer</button>
<button type="button" class="conc-info-btn conc-info-btn--linha" data-ver-vinculo="${lanc.vinculo_id}" aria-label="Ver o vínculo" data-conc-tooltip="Ver o que este vínculo considerou" data-conc-tooltip-sempre>${PID_CONC_ICONE_INFO}</button>
</span>
</td>
</tr>`;
}
async function desfazerVinculo(vinculoId) {
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculoId);
const lista = itens
.map((l) => `${pidConcData(l.data)} ${l.natureza === "C" ? "crédito" : "débito"} ${pidConcMoeda(l.valor)}`)
.join("; ");
// Conta validada perde a validação ao desfazer (servidor, rodada 78).
const avisoValidacao = conta.validada ? ' A conta está validada: a validação será removida e ela volta para "Pendentes".' : "";
const confirmado = await pidConfirm(
`Desfazer o vínculo #${numeroVinculo.get(vinculoId) || ""} (${lista})? Os lançamentos voltam para não conciliados.${avisoValidacao}`,
{ titulo: "Desfazer vínculo", textoConfirmar: "Desfazer" }
);
if (!confirmado) return;
try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculoId}/`, { method: "DELETE" });
aplicarRespostaConta(resposta);
} catch (erro) {
pidAlert(erro.message, { titulo: "Não foi possível desfazer" });
}
}
// Linhas vinculadas: destacar os outros itens do mesmo vínculo ao passar
// o mouse, e "Desfazer" direto na linha.
secaoPend.querySelectorAll("tr[data-vinculo-id]").forEach((tr) => {
const grupo = () => secaoPend.querySelectorAll(`tr[data-vinculo-id="${tr.dataset.vinculoId}"]`);
tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo")));
tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-grupo")));
});
// Sugestão: ao passar o mouse, destaca a própria linha e as do outro
// lado da sugestão (pedido do usuário, como o destaque do vínculo). Id
// negativo é o saldo de um vínculo parcial: destaca as linhas dele.
secaoPend.querySelectorAll("[data-conc-relacionados]").forEach((sugestaoEl) => {
const linha = sugestaoEl.closest("tr");
const ids = sugestaoEl.dataset.concRelacionados.split(",").filter(Boolean).map(Number);
const grupo = () => [
linha,
...ids.flatMap((id) =>
Array.from(secaoPend.querySelectorAll(id < 0 ? `tr[data-vinculo-id="${-id}"]` : `tr[data-lanc-id="${id}"]`))
),
];
sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo")));
sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo")));
// Duplo clique seleciona a linha e as relacionadas; se já estavam todas
// selecionadas, desmarca (pedido do usuário). Só as com checkbox: as não
// vinculadas e a linha do saldo a pagar de um pagamento parcial.
sugestaoEl.addEventListener("dblclick", (evento) => {
evento.stopPropagation();
if (window.getSelection) window.getSelection().removeAllRanges();
const checks = grupo()
.map((tr) => tr.querySelector("[data-lanc-check]"))
.filter(Boolean);
const marcar = !checks.every((check) => check.checked);
checks.forEach((check) => {
if (check.checked === marcar) return;
check.checked = marcar;
check.dispatchEvent(new Event("change"));
});
});
});
secaoPend.querySelector("[data-historico-vinculos]").addEventListener("click", (evento) => {
evento.stopPropagation();
pidConcAbreHistoricoVinculos(conta);
});
// "Ocultar conciliados": desmarcado por padrão (pedido do usuário); a
// escolha fica no estado da conta e sobrevive ao re-render após um vínculo.
const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]");
if (ocultarCheck) {
ocultarCheck.addEventListener("click", (evento) => evento.stopPropagation());
ocultarCheck.addEventListener("change", () => {
estado.ocultarConciliados = ocultarCheck.checked;
const tabelaRazao = secaoPend.querySelector(".conc-subtabela--pendentes");
if (tabelaRazao) tabelaRazao.classList.toggle("is-ocultando-conciliados", ocultarCheck.checked);
});
}
secaoPend.querySelectorAll("[data-ver-vinculo]").forEach((btn) => {
btn.addEventListener("click", (evento) => {
evento.stopPropagation();
const vinculoId = Number(btn.dataset.verVinculo);
pidConcAbreDetalheVinculo(conta, vinculoId, numeroVinculo.get(vinculoId));
});
});
secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => {
btn.addEventListener("click", (evento) => {
evento.stopPropagation();
desfazerVinculo(Number(btn.dataset.desfazerVinculo));
});
});
const resumoSelecaoEl = secaoPend.querySelector("[data-selecao-resumo]");
const todosCheck = secaoPend.querySelector("[data-lanc-todos]");
const vincularBtn = secaoPend.querySelector("[data-vincular]");
const porId = new Map(conta.lancamentos.map((l) => [l.id, l]));
// O saldo a pagar de cada pagamento parcial entra na seleção como um
// crédito a mais (id negativo = -id do vínculo), só para a soma da barra
// e para escolher o vínculo que recebe os demais selecionados.
conta.vinculos
.filter((v) => v.tipo === "parcial")
.forEach((v) => porId.set(-v.id, { id: -v.id, natureza: "C", valor: v.diferenca, saldoDoVinculo: v }));
function atualizarSelecao() {
const selecionados = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean);
const lancamentos = selecionados.filter((l) => !l.saldoDoVinculo);
const saldos = selecionados.filter((l) => l.saldoDoVinculo);
const creditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
const debitos = selecionados.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
if (saldos.length) {
// Com um saldo marcado, a barra acrescenta ao vínculo dele.
vincularBtn.textContent =
saldos.length === 1 ? `Adicionar ao vínculo #${numeroVinculo.get(saldos[0].saldoDoVinculo.id) || ""}` : "Marque só um saldo a pagar";
vincularBtn.disabled = saldos.length !== 1 || lancamentos.length === 0;
} else {
const temAmbos = lancamentos.some((l) => l.natureza === "C") && lancamentos.some((l) => l.natureza === "D");
vincularBtn.textContent = "Vincular selecionados";
vincularBtn.disabled = !temAmbos;
}
// A dica fica no invólucro: botão desabilitado não recebe o mouse
// (`pointer-events: none` no CSS), e ela só explica o botão desabilitado.
painel.querySelectorAll("[data-adicionar]").forEach((btn) => {
btn.disabled = lancamentos.length === 0;
const envolto = btn.closest("[data-adicionar-dica]");
if (btn.disabled) {
envolto.dataset.concTooltip = "Marque lançamentos na lista de não conciliados";
envolto.dataset.concTooltipSempre = "";
} else {
delete envolto.dataset.concTooltip;
delete envolto.dataset.concTooltipSempre;
}
});
if (todosCheck) {
todosCheck.checked = lancamentos.length > 0 && lancamentos.length === pendentes.length;
todosCheck.indeterminate = lancamentos.length > 0 && lancamentos.length < pendentes.length;
todosCheck.dataset.concTooltip = lancamentos.length ? "Desmarcar todos" : "Marcar todos";
}
resumoSelecaoEl.innerHTML = selecionados.length
? `Créditos <strong>${pidConcMoeda(creditos)}</strong> · Débitos <strong>${pidConcMoeda(debitos)}</strong> · Diferença <strong>${pidConcMoeda(creditos - debitos)}</strong>`
: "Selecione débitos e créditos para vincular manualmente.";
return { selecionados: lancamentos, saldos, diferenca: Math.round((creditos - debitos) * 100) / 100 };
}
atualizarSelecao();
// Cabeçalho: sem nada marcado, marca todos; com algum marcado (parcial ou
// todos), desmarca todos. "Todos" são os lançamentos; o saldo a pagar só
// é desmarcado junto, nunca marcado (vale um vínculo por vez).
if (todosCheck) {
todosCheck.addEventListener("change", () => {
const marcar = estado.selecionados.size === 0;
secaoPend.querySelectorAll(marcar ? "[data-lanc-check]:not([data-saldo-check])" : "[data-lanc-check]").forEach((check) => {
check.checked = marcar;
check.closest("tr").classList.toggle("is-selecionado", marcar);
const id = Number(check.dataset.lancCheck);
if (marcar) estado.selecionados.add(id);
else estado.selecionados.delete(id);
});
atualizarSelecao();
});
}
// A linha inteira marca/desmarca (pedido do usuário); o checkbox fica
// como indicador. Clique no próprio checkbox segue o comportamento
// nativo, e arrastar para selecionar texto não conta como clique.
secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id]), .conc-subtabela--pendentes tbody tr[data-saldo-vinculo]").forEach((tr) => {
tr.addEventListener("click", (evento) => {
if (evento.target.closest("[data-lanc-check]")) return;
// A sugestão é reservada ao duplo clique (seleciona o grupo); o
// clique simples nela marcaria/desmarcaria a linha duas vezes antes.
if (evento.target.closest("[data-conc-relacionados]")) return;
if (pidConcFoiArraste(evento)) return;
const check = tr.querySelector("[data-lanc-check]");
check.checked = !check.checked;
check.dispatchEvent(new Event("change"));
});
});
secaoPend.querySelectorAll("[data-lanc-check]").forEach((check) => {
check.addEventListener("change", () => {
const id = Number(check.dataset.lancCheck);
if (check.checked) estado.selecionados.add(id);
else estado.selecionados.delete(id);
check.closest("tr").classList.toggle("is-selecionado", check.checked);
atualizarSelecao();
});
});
// Acrescenta lançamentos a um vínculo manual/parcial (botão do bloco do
// vínculo, ou a barra com o saldo a pagar dele marcado).
async function adicionarAoVinculo(vinculo, novos, botao) {
if (!novos.length) return;
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
const assinado = (l) => (l.natureza === "C" ? Number(l.valor) : -Number(l.valor));
const novaDiferenca = [...itens, ...novos].reduce((t, l) => t + assinado(l), 0);
const arredondada = Math.round(novaDiferenca * 100) / 100;
const numero = numeroVinculo.get(vinculo.id) || "";
const totalCreditos = [...itens, ...novos].filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
let fechar = false;
if (arredondada > limiteTolerancia(totalCreditos)) {
// mesma escolha da criação: parcelamento ou desconto
const escolha = await pidConcEscolheAjuste(
`Com ${novos.length} lançamento(s) a mais, o vínculo #${numero} fica com ${pidConcMoeda(arredondada)} de títulos sem pagamento. Como tratar a diferença?`,
arredondada
);
if (!escolha) return;
fechar = escolha === "desconto";
} else {
const efeito =
arredondada > 0
? `fecha com ${pidConcMoeda(arredondada)} de desconto`
: arredondada < 0
? `fecha pago a maior em ${pidConcMoeda(-arredondada)} (juros)`
: "fecha sem diferença";
const seguir = await pidConfirm(
`Adicionar ${novos.length} lançamento(s) ao vínculo #${numero}? Com eles, o vínculo ${efeito}.`,
{ titulo: "Adicionar ao vínculo", textoConfirmar: "Adicionar" }
);
if (!seguir) return;
}
botao.disabled = true;
try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculo.id}/adicionar/`, {
method: "POST",
body: { lancamentos: novos.map((l) => l.id), fechar },
});
aplicarRespostaConta(resposta);
} catch (erro) {
botao.disabled = false;
pidAlert(erro.message, { titulo: "Não foi possível adicionar" });
}
}
vincularBtn.addEventListener("click", async () => {
const { selecionados, saldos, diferenca } = atualizarSelecao();
if (saldos.length) {
if (saldos.length === 1) adicionarAoVinculo(saldos[0].saldoDoVinculo, selecionados, vincularBtn);
return;
}
// Mesmo critério do servidor (vincular/): créditos maiores que os
// débitos além da tolerância = o contador escolhe entre parcelamento
// (o título continua em aberto pelo saldo) e desconto (fecha).
const totalCreditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
let fechar = false;
if (diferenca > limiteTolerancia(totalCreditos)) {
const escolha = await pidConcEscolheAjuste(
`Os débitos selecionados pagam ${pidConcMoeda(totalCreditos - diferenca)} de ${pidConcMoeda(totalCreditos)} em títulos. Como tratar a diferença?`,
diferenca
);
if (!escolha) return;
fechar = escolha === "desconto";
} else if (diferenca !== 0) {
const natureza = diferenca < 0 ? "pago a maior: será registrada como juros" : "pago a menor: será registrada como desconto";
const seguir = await pidConfirm(
`Os lançamentos selecionados têm diferença de ${pidConcMoeda(Math.abs(diferenca))} (${natureza}). Vincular mesmo assim? O vínculo fica sinalizado nos alertas da conta.`,
{ titulo: diferenca < 0 ? "Vínculo com juros" : "Vínculo com desconto", textoConfirmar: "Vincular" }
);
if (!seguir) return;
}
vincularBtn.disabled = true;
try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/vincular/`, {
method: "POST",
body: { lancamentos: selecionados.map((l) => l.id), fechar },
});
aplicarRespostaConta(resposta);
} catch (erro) {
vincularBtn.disabled = false;
pidAlert(erro.message, { titulo: "Não foi possível vincular" });
}
});
// ---- Vinculados ----
function montarBlocoVinculo(vinculo) {
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
const manual = vinculo.tipo === "manual" || vinculo.tipo === "parcial";
const origem = manual ? `manual, por ${pidConcEscape(vinculo.criado_por_nome || "—")}` : "automático";
const diferenca = Number(vinculo.diferenca);
let detalheDiferenca = "";
if (vinculo.tipo === "parcial") {
const saldo = vinculo.situacao;
detalheDiferenca = ` · restam <strong>${saldo ? pidConcValorAbertoHtml(saldo) : pidConcMoeda(diferenca)}</strong> 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 = `
<div class="conc-vinculo__header">
<span><strong>#${numeroVinculo.get(vinculo.id)} ${pidConcEscape(vinculo.tipo_rotulo)}</strong> · ${origem}${detalheDiferenca}</span>
<span class="conc-vinculo__acoes">
${manual ? '<span class="conc-adicionar-dica" data-adicionar-dica><button type="button" class="btn-outline conc-btn-sm" data-adicionar disabled>Adicionar selecionados</button></span>' : ""}
<button type="button" class="btn-outline conc-btn-sm" data-desfazer>Desfazer vínculo</button>
</span>
</div>
<table class="conc-subtabela">
<thead><tr><th>Data</th><th>Seq.</th><th>Histórico</th><th>Natureza</th><th class="conc-num">Valor</th></tr></thead>
<tbody>${itens.map((l) => linhaLancamentoHtml(l, false, false)).join("")}</tbody>
</table>`;
const adicionarBtn = bloco.querySelector("[data-adicionar]");
if (adicionarBtn) {
adicionarBtn.addEventListener("click", () => {
const novos = Array.from(estado.selecionados).map((id) => porId.get(id)).filter((l) => l && !l.saldoDoVinculo);
adicionarAoVinculo(vinculo, novos, adicionarBtn);
});
}
bloco.querySelector("[data-desfazer]").addEventListener("click", () => desfazerVinculo(vinculo.id));
return bloco;
}
// Pagamento parcial não é conciliação fechada: fica junto dos não
// conciliados, sempre visível, com o saldo que continua em aberto.
const parciais = conta.vinculos.filter((v) => v.tipo === "parcial");
if (parciais.length) {
const secaoParcial = document.createElement("div");
secaoParcial.innerHTML = `<div class="conc-detail__secao-titulo"><h4>Pagamentos parciais vinculados (${parciais.length})</h4></div>`;
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 = `
<div class="conc-detail__secao-titulo">
<h4>Lançamentos conciliados (${fechados.length} vínculo(s))</h4>
<button type="button" class="btn-outline conc-btn-sm" data-toggle-vinculados>${estado.mostrarVinculados ? "Ocultar" : "Mostrar"}</button>
</div>`;
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 = `
<label class="conc-detail__secao-titulo"><h4>Observação do contador</h4></label>
<textarea data-observacao placeholder="Registre a análise desta conta (ex.: título já pago em outubro, aguardando baixa)…">${pidConcEscape(rascunhos.has(conta.id) ? rascunhos.get(conta.id) : conta.observacao)}</textarea>
<div class="conc-detail__acoes">
<button type="button" class="btn-outline conc-btn-sm" data-salvar-obs>Salvar observação</button>
<button type="button" class="${conta.validada ? "btn-outline" : "btn-solid"} conc-btn-sm" data-validar>${conta.validada ? "Remover validação" : "Marcar como validada"}</button>
${conta.validada ? `<span class="conc-validacao__info">${pidConcEscape(pidConcTextoValidacao(conta))}</span>` : ""}
<button type="button" class="btn-outline conc-btn-sm conc-recolher-btn" data-recolher>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 15l6-6 6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
Recolher
</button>
</div>`;
const observacaoEl = validacao.querySelector("[data-observacao]");
observacaoEl.addEventListener("input", () => rascunhos.set(conta.id, observacaoEl.value));
const botoesValidacao = validacao.querySelectorAll("[data-salvar-obs], [data-validar]");
async function salvarValidacao(validada) {
// travados até a resposta: um clique duplo mandaria duas vezes
botoesValidacao.forEach((btn) => { btn.disabled = true; });
if (validada !== conta.validada && !(await confirmaMudancaDeChip(conta, validada))) {
botoesValidacao.forEach((btn) => { btn.disabled = false; });
return;
}
try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, {
method: "POST",
body: { validada, observacao: observacaoEl.value },
});
rascunhos.delete(conta.id);
aplicarRespostaConta(resposta);
} catch (erro) {
botoesValidacao.forEach((btn) => { btn.disabled = false; });
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
}
}
validacao.querySelector("[data-salvar-obs]").addEventListener("click", () => salvarValidacao(conta.validada));
validacao.querySelector("[data-validar]").addEventListener("click", () => salvarValidacao(!conta.validada));
validacao.querySelector("[data-recolher]").addEventListener("click", () => {
abertas.delete(conta.id);
substituirConta(conta);
// a expansão pode ser longa: volta a tela para a linha do fornecedor
const linha = contasBody.querySelector(`tr[data-conta-id="${conta.id}"]`);
if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" });
});
painel.appendChild(validacao);
return painel;
}
// ======================================================================
// Tela "Em aberto há mais de 3 meses"
// ======================================================================
// Pedido do usuário: título antigo sem pagamento não é bloqueio para a
// análise (não deixa mais a conta em "Atenção"), mas precisa de revisão
// própria. Esta aba lista os fornecedores com esses títulos, com
// validação e observação por fornecedor (`validar-vencidos/`), separadas
// da validação da conciliação.
const abaBtns = Array.from(document.querySelectorAll("[data-conc-aba]"));
const abaPaineis = Array.from(document.querySelectorAll("[data-conc-painel]"));
const abaVencidosQtd = document.getElementById("conc-aba-vencidos-qtd");
const vencBody = document.getElementById("conc-venc-body");
const vencVazia = document.getElementById("conc-venc-vazia");
const vencBusca = document.getElementById("conc-venc-busca");
const vencFiltroBtns = Array.from(document.querySelectorAll("[data-conc-venc]"));
const vencSortHeaders = Array.from(document.querySelectorAll("#conc-venc-table th[data-venc-sort]"));
let abaAtual = "conciliacao";
let vencFiltro = "pendentes";
let vencSortKey = "total_vencido";
let vencSortDir = -1;
const vencAbertas = new Set();
function mostrarAba(aba) {
abaAtual = aba;
abaBtns.forEach((btn) => btn.classList.toggle("is-active", btn.dataset.concAba === aba));
abaPaineis.forEach((painel) => { painel.hidden = painel.dataset.concPainel !== aba; });
if (!detalhe) return;
renderCards();
if (aba === "vencidos") renderVencidos();
else if (aba === "relatorio") carregarRelatorio();
else renderContas();
}
abaBtns.forEach((btn) => btn.addEventListener("click", () => mostrarAba(btn.dataset.concAba)));
// ---- Aba "Relatório" (pedidos do usuário, rodadas 71 e 72) ----
// Uso interno do contador: o servidor devolve um trecho de HTML no visual
// do Portal (template com autoescape do Django), inserido aqui. Gerado de
// novo a cada abertura da aba, porque a conciliação muda enquanto o
// contador trabalha.
const relatorioConteudo = document.getElementById("conc-relatorio-conteudo");
const relatorioGerado = document.getElementById("conc-relatorio-gerado");
const relatorioImprimirBtn = document.getElementById("conc-relatorio-imprimir");
const relatorioAnexoInput = document.getElementById("conc-relatorio-anexo");
let relatorioPedido = 0;
async function carregarRelatorio() {
const pedido = ++relatorioPedido;
const conciliacaoId = detalhe.id;
atualizarUrl();
relatorioGerado.textContent = "Gerando relatório…";
relatorioImprimirBtn.disabled = true;
// relatório de outra conciliação não fica à vista enquanto carrega
if (relatorioConteudo.dataset.conciliacaoId !== String(conciliacaoId)) relatorioConteudo.innerHTML = "";
relatorioConteudo.classList.add("is-carregando");
try {
const resposta = await fetch(`${PID_API_BASE}/conciliacoes-fornecedores/${conciliacaoId}/relatorio/`, { credentials: "include" });
if (resposta.status === 401) {
window.location.href = "index.html";
return;
}
if (!resposta.ok) throw new Error(`Não foi possível gerar o relatório (erro ${resposta.status}).`);
const html = await resposta.text();
// trocou de conciliação ou pediu outro relatório no meio do caminho
if (pedido !== relatorioPedido || !detalhe || detalhe.id !== conciliacaoId) return;
relatorioConteudo.innerHTML = html;
relatorioConteudo.dataset.conciliacaoId = String(conciliacaoId);
relatorioGerado.textContent = `Situação em ${pidConcDataHora(new Date().toISOString())}`;
relatorioImprimirBtn.disabled = false;
} catch (erro) {
if (pedido !== relatorioPedido) return;
relatorioConteudo.innerHTML = "";
relatorioGerado.textContent = erro.message;
} finally {
if (pedido === relatorioPedido) relatorioConteudo.classList.remove("is-carregando");
}
}
document.getElementById("conc-relatorio-recarregar").addEventListener("click", () => {
if (detalhe) carregarRelatorio();
});
// Impressão: uma cópia do relatório vai para um contêiner direto no body
// (`#conc-impressao`) e o CSS de impressão esconde todo o resto do Portal,
// com paleta clara fixa em A4. Na cópia, todos os blocos abrem; o anexo
// só sai se marcado.
relatorioImprimirBtn.addEventListener("click", () => {
const original = relatorioConteudo.querySelector(".conc-rel");
if (!original) return;
const impressao = document.createElement("div");
impressao.id = "conc-impressao";
const copia = original.cloneNode(true);
copia.querySelectorAll("details").forEach((d) => { d.open = true; });
if (!relatorioAnexoInput.checked) copia.querySelectorAll(".conc-rel-anexo-secao").forEach((s) => s.remove());
impressao.appendChild(copia);
document.body.appendChild(impressao);
document.body.classList.add("conc-imprimindo");
const limpar = () => {
document.body.classList.remove("conc-imprimindo");
impressao.remove();
window.removeEventListener("afterprint", limpar);
};
window.addEventListener("afterprint", limpar);
window.print();
});
function contasVencidas() {
return detalhe.contas.filter((c) => Number(c.total_vencido) > 0);
}
/** Títulos da conta em aberto há mais de 3 meses, do mais antigo ao mais
* recente: lançamentos sem vínculo e saldos de pagamento parcial. */
function titulosVencidos(conta) {
const itens = conta.lancamentos
.filter((l) => l.situacao && l.situacao.categoria === "vencido")
.map((l) => ({ lanc: l, valor: l.situacao.valor_em_aberto }));
conta.vinculos.forEach((v) => {
if (!v.situacao || v.situacao.categoria !== "vencido") return;
const titulo = conta.lancamentos
.filter((l) => l.vinculo_id === v.id && l.natureza === "C")
.sort((a, b) => a.data.localeCompare(b.data))[0];
if (titulo) {
itens.push({ lanc: { ...titulo, historico: `${titulo.historico} (saldo do pagamento parcial)` }, valor: v.situacao.valor_em_aberto });
}
});
return itens.sort((a, b) => a.lanc.data.localeCompare(b.lanc.data));
}
function diasEmAberto(iso) {
const dias = Math.round((Date.parse(detalhe.data_base) - Date.parse(iso.slice(0, 10))) / 86400000);
return `${dias} dias`;
}
function textoValidacaoVencidos(conta) {
if (!conta.vencidos_validados) return "";
return `Validado por ${conta.vencidos_validados_por_nome || "—"} em ${pidConcDataHora(conta.vencidos_validados_em)}`;
}
function vencPassaFiltro(c) {
if (Number(c.total_vencido) <= 0) return false;
if (vencFiltro === "pendentes" && c.vencidos_validados) return false;
if (vencFiltro === "validados" && !c.vencidos_validados) return false;
const termo = vencBusca.value.trim().toLowerCase();
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
}
const vencSortValor = {
conta_numero: (c) => Number(c.conta_numero),
fornecedor: (c) => c.fornecedor,
qtd_vencidos: (c) => c.qtd_vencidos,
mais_antigo: (c) => (titulosVencidos(c)[0] || { lanc: { data: "" } }).lanc.data,
total_vencido: (c) => Number(c.total_vencido),
vencidos_validados: (c) => (c.vencidos_validados ? 1 : 0),
};
vencSortHeaders.forEach((th) => {
th.addEventListener("click", () => {
const chave = th.dataset.vencSort;
vencSortDir = vencSortKey === chave ? vencSortDir * -1 : 1;
vencSortKey = chave;
renderVencidos();
});
});
vencFiltroBtns.forEach((btn) => {
btn.addEventListener("click", () => {
vencFiltro = btn.dataset.concVenc;
renderVencidos();
});
});
vencBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderVencidos()));
function atualizarContagemVencidos() {
const todas = contasVencidas();
const validadas = todas.filter((c) => c.vencidos_validados).length;
const contagem = { pendentes: todas.length - validadas, validados: validadas, "": todas.length };
vencFiltroBtns.forEach((btn) => {
const chave = btn.dataset.concVenc;
btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} <span class="conc-chip__count">${contagem[chave]}</span>`;
btn.classList.toggle("is-active", vencFiltro === chave);
});
vencSortHeaders.forEach((h) => {
h.classList.toggle("is-sort-asc", h.dataset.vencSort === vencSortKey && vencSortDir === 1);
h.classList.toggle("is-sort-desc", h.dataset.vencSort === vencSortKey && vencSortDir === -1);
});
return todas.length;
}
function mensagemVencidosVazia(totalComVencidos) {
if (!totalComVencidos) return "Nenhum título em aberto há mais de 3 meses nesta conciliação.";
if (vencFiltro === "pendentes" && !vencBusca.value.trim()) return "Todos os fornecedores com títulos antigos já foram validados.";
return "Nenhum fornecedor para este filtro.";
}
function renderVencidos() {
const total = atualizarContagemVencidos();
const extrator = vencSortValor[vencSortKey];
const lista = detalhe.contas
.filter(vencPassaFiltro)
.sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * vencSortDir);
vencBody.innerHTML = "";
vencVazia.hidden = lista.length > 0;
vencVazia.textContent = mensagemVencidosVazia(total);
const fragmento = document.createDocumentFragment();
lista.forEach((conta) => montarLinhasVencidos(conta).forEach((tr) => fragmento.appendChild(tr)));
vencBody.appendChild(fragmento);
atualizarUrl();
}
function montarLinhasVencidos(conta) {
const aberta = vencAbertas.has(conta.id);
const titulos = titulosVencidos(conta);
const maisAntigo = titulos[0] ? titulos[0].lanc.data : null;
const tr = document.createElement("tr");
tr.className = `conc-row${aberta ? " is-aberta" : ""}`;
tr.dataset.vencContaId = conta.id;
const subtitulo = textoValidacaoVencidos(conta) || conta.vencidos_observacao || "";
tr.innerHTML = `
<td>${PID_CONC_CHEVRON}</td>
<td class="conc-conta-numero">${pidConcEscape(conta.conta_numero)}</td>
<td><span class="conc-fornecedor__nome">${pidConcEscape(conta.fornecedor)}</span><span class="conc-fornecedor__alerta">${pidConcEscape(subtitulo)}</span></td>
<td class="conc-num">${titulos.length}</td>
<td>${pidConcData(maisAntigo)}${maisAntigo ? ` <span class="conc-venc-dias">· ${diasEmAberto(maisAntigo)}</span>` : ""}</td>
<td class="conc-num conc-valor conc-valor--vencido">${pidConcMoeda(conta.total_vencido)}</td>
<td class="conc-validado-col"><input type="checkbox" class="conc-validado-check" data-venc-check ${conta.vencidos_validados ? "checked" : ""} aria-label="Confirmar que os títulos antigos do fornecedor foram validados" /></td>`;
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 = `
<div>
<div class="conc-detail__secao-titulo"><h4>Títulos em aberto há mais de 3 meses (${titulos.length})</h4></div>
<table class="conc-subtabela">
<thead><tr><th>Data</th><th>Seq.</th><th>Histórico</th><th class="conc-num">Valor em aberto</th><th class="conc-num">Em aberto há</th></tr></thead>
<tbody>${titulos
.map(
(t) => `
<tr>
<td>${pidConcData(t.lanc.data)}</td>
<td>${t.lanc.eh_saldo_anterior ? "—" : pidConcEscape(t.lanc.sequencia || "—")}</td>
${pidConcHistoricoHtml(t.lanc)}
<td class="conc-num conc-valor">${pidConcMoeda(t.valor)}</td>
<td class="conc-num">${diasEmAberto(t.lanc.data)}</td>
</tr>`
)
.join("")}</tbody>
</table>
</div>
<div class="conc-validacao">
<label class="conc-detail__secao-titulo"><h4>Observação sobre os títulos antigos</h4></label>
<textarea data-venc-observacao placeholder="Registre a análise (ex.: títulos já pagos, aguardando baixa no Questor)…">${pidConcEscape(rascunhosVenc.has(conta.id) ? rascunhosVenc.get(conta.id) : conta.vencidos_observacao)}</textarea>
<div class="conc-detail__acoes">
<button type="button" class="btn-outline conc-btn-sm" data-venc-salvar-obs>Salvar observação</button>
<button type="button" class="${conta.vencidos_validados ? "btn-outline" : "btn-solid"} conc-btn-sm" data-venc-validar>${conta.vencidos_validados ? "Remover validação" : "Marcar como validado"}</button>
${conta.vencidos_validados ? `<span class="conc-validacao__info">${pidConcEscape(textoValidacaoVencidos(conta))}</span>` : ""}
<button type="button" class="btn-outline conc-btn-sm conc-recolher-btn" data-venc-recolher>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 15l6-6 6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
Recolher
</button>
</div>
</div>`;
const observacaoEl = painel.querySelector("[data-venc-observacao]");
observacaoEl.addEventListener("input", () => rascunhosVenc.set(conta.id, observacaoEl.value));
const botoesValidacao = painel.querySelectorAll("[data-venc-salvar-obs], [data-venc-validar]");
const salvar = async (validada) => {
botoesValidacao.forEach((btn) => { btn.disabled = true; });
try {
await salvarVencidos(conta, validada, observacaoEl.value);
} catch (erro) {
botoesValidacao.forEach((btn) => { btn.disabled = false; });
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
}
};
painel.querySelector("[data-venc-salvar-obs]").addEventListener("click", () => salvar(conta.vencidos_validados));
painel.querySelector("[data-venc-validar]").addEventListener("click", () => salvar(!conta.vencidos_validados));
painel.querySelector("[data-venc-recolher]").addEventListener("click", () => {
vencAbertas.delete(conta.id);
substituirVencidos(conta);
const linha = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`);
if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" });
});
return painel;
}
function substituirVencidos(conta) {
const antiga = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`);
if (!antiga) return;
const detalheAntigo = vencBody.querySelector(`tr[data-venc-detalhe-id="${conta.id}"]`);
antiga.replaceWith(...montarLinhasVencidos(conta));
if (detalheAntigo) detalheAntigo.remove();
}
function removerVencidos(contaId) {
const linhas = vencBody.querySelectorAll(`tr[data-venc-conta-id="${contaId}"], tr[data-venc-detalhe-id="${contaId}"]`);
linhas.forEach((tr) => tr.classList.add("is-saindo"));
window.setTimeout(() => {
linhas.forEach((tr) => tr.remove());
if (!vencBody.querySelector("tr[data-venc-conta-id]")) {
vencVazia.hidden = false;
vencVazia.textContent = mensagemVencidosVazia(contasVencidas().length);
}
}, 160);
}
/** Grava a validação/observação dos títulos antigos e atualiza a linha
* (sai da lista com fade se deixar de caber no filtro, como na conciliação). */
async function salvarVencidos(conta, validada, observacao) {
const corpo = { validada };
if (observacao !== undefined) corpo.observacao = observacao;
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar-vencidos/`, {
method: "POST",
body: corpo,
});
if (observacao !== undefined) rascunhosVenc.delete(conta.id);
const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id);
const eraValidada = indice >= 0 && detalhe.contas[indice].vencidos_validados;
if (indice >= 0) detalhe.contas[indice] = resposta.conta;
if (!eraValidada && resposta.conta.vencidos_validados) vencAbertas.delete(resposta.conta.id);
detalhe.resumo = resposta.resumo;
renderCards();
atualizarContagemVencidos();
if (vencPassaFiltro(resposta.conta)) substituirVencidos(resposta.conta);
else removerVencidos(resposta.conta.id);
}
// ======================================================================
// Início
// ======================================================================
const hash = pidConcLeHash();
if (hash) {
abrirDetalhe(hash.id, { params: hash.params });
} else {
carregarHistorico();
}
});