portal_publico/static/js/conciliacao-fornecedores.js

2111 lines
103 KiB
JavaScript

/**
* 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: "Divergente", pill: "status-pill--danger", rank: 0 },
atencao: { rotulo: "Atenção", pill: "status-pill--warning", rank: 1 },
conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 2 },
};
const PID_CONC_CHEVRON =
'<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 = {
debito_excedente: "Pagamentos sem título em aberto",
pagamento_sem_titulo: "Pagamentos sem título de mesmo valor",
diferenca_vinculo: "Diferenças de juros/desconto",
conta_transitoria: "Conta transitória com saldo",
saldo_devedor: "Saldo devedor",
saldo_divergente_arquivo: "Saldo diferente do arquivo",
};
const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" };
// Delegado ao `pidEscapeHtml` (api.js), que já escapa aspas: serve para
// conteúdo e para atributo. `pidConcEscapeAttr` fica como sinônimo.
function pidConcEscape(texto) {
return pidEscapeHtml(texto);
}
function pidConcEscapeAttr(texto) {
return pidEscapeHtml(texto);
}
/** Célula de histórico em uma linha só (reticências + texto completo no
* hover); a contrapartida segue na segunda linha, também cortada. */
function pidConcHistoricoHtml(lanc) {
const texto = lanc.eh_saldo_anterior ? "Saldo anterior" : lanc.historico || "";
const historico = lanc.eh_saldo_anterior ? "<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;
if (!("concTooltipSempre" in alvo.dataset) && alvo.scrollWidth <= alvo.clientWidth) return;
if (!pidConcTooltipEl) {
pidConcTooltipEl = document.createElement("div");
pidConcTooltipEl.className = "conc-tooltip";
pidConcTooltipEl.setAttribute("role", "tooltip");
document.body.appendChild(pidConcTooltipEl);
}
pidConcTooltipEl.textContent = alvo.dataset.concTooltip;
pidConcTooltipEl.hidden = false;
const margem = 8;
const rect = alvo.getBoundingClientRect();
const largura = pidConcTooltipEl.offsetWidth;
const altura = pidConcTooltipEl.offsetHeight;
const esquerda = Math.max(margem, Math.min(rect.left, window.innerWidth - largura - margem));
let topo = rect.top - altura - margem;
if (topo < margem) topo = rect.bottom + margem;
pidConcTooltipEl.style.left = `${esquerda}px`;
pidConcTooltipEl.style.top = `${topo}px`;
}
function pidConcEscondeTooltip(evento) {
const alvo = evento.target.closest && evento.target.closest("[data-conc-tooltip]");
if (!alvo || !pidConcTooltipEl || alvo.contains(evento.relatedTarget)) return;
pidConcTooltipEl.hidden = true;
}
document.addEventListener("mouseover", pidConcMostraTooltip);
document.addEventListener("mouseout", pidConcEscondeTooltip);
window.addEventListener("scroll", () => { if (pidConcTooltipEl) pidConcTooltipEl.hidden = true; }, true);
function pidConcMoeda(valor) {
return PID_CONC_MOEDA.format(Number(valor) || 0);
}
const PID_CONC_ICONE_INFO =
'<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}.`;
const resultado = diferenca === 0
? "Fecha sem diferença."
: diferenca > 0
? `Créditos ${pidConcMoeda(diferenca)} maiores que os débitos${vinculo.tipo === "parcial" ? " (em aberto)" : ""}.`
: `Débitos ${pidConcMoeda(-diferenca)} maiores que os créditos.`;
const corpo = pidConcAbreModalInfo(`Vínculo #${numero || "?"}`, `${conta.conta_numero} - ${conta.fornecedor}`);
corpo.innerHTML = `
<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>
<span>${resultado}</span>
</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 || {};
const conciliados = porStatus.conciliada || 0;
const periodo = item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "—";
const tr = document.createElement("tr");
tr.className = "conc-row";
tr.innerHTML = `
<td><span class="pa-name-link">${pidConcEscape(item.codigo_empresa)} - ${pidConcEscape(item.nome_empresa || "Empresa não encontrada no Questor")}</span></td>
<td>${pidConcData(item.data_base)}</td>
<td>${periodo}</td>
<td class="conc-num">${resumo.total_contas ?? "—"}</td>
<td class="conc-num">${conciliados}</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(item.criado_por_nome || "—")}<span class="conc-fornecedor__alerta">${pidConcDataHora(item.criado_em)}${
item.ultima_atualizacao ? ` · atualizado em ${pidConcDataHora(item.ultima_atualizacao.criado_em)}` : ""
}</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 = [
`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("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 filtroStatusBtns = Array.from(document.querySelectorAll("[data-conc-status]"));
const filtroAlertaEl = document.getElementById("conc-filtro-alerta");
const sortHeaders = Array.from(document.querySelectorAll("#conc-contas-table th[data-sort]"));
const mostrarValidadasInput = document.getElementById("conc-mostrar-validadas");
const mostrarValidadasQtd = document.getElementById("conc-mostrar-validadas-qtd");
let detalhe = null;
let filtroStatus = "";
let filtroAlerta = "";
// Padrão: por situação, divergentes primeiro (pedido do usuário).
const SORT_PADRAO = "status";
let sortKey = SORT_PADRAO;
let sortDir = 1;
const abertas = new Set();
// estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados
const estadoConta = new Map();
// 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&status=divergente&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 === "vencidos") params.set("aba", "vencidos");
if (filtroStatus) params.set("status", filtroStatus);
if (filtroAlerta) params.set("alerta", filtroAlerta);
if (mostrarValidadasInput.checked) params.set("validadas", "1");
if (sortKey !== SORT_PADRAO || sortDir !== 1) 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 status = params.get("status") || "";
if (status === "validada" || PID_CONC_STATUS[status]) filtroStatus = status;
const alerta = params.get("alerta") || "";
if (PID_CONC_ROTULO_ALERTA[alerta]) filtroAlerta = alerta;
mostrarValidadasInput.checked = params.get("validadas") === "1";
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="8" 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");
filtroStatus = "";
filtroAlerta = "";
contasBusca.value = "";
mostrarValidadasInput.checked = false;
sortKey = SORT_PADRAO;
sortDir = 1;
aplicarParametros(params);
atualizarSortHeaders();
try {
detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`);
} catch (erro) {
contasBody.innerHTML = `<tr><td colspan="8" class="conc-empty">${pidConcEscape(erro.message)}</td></tr>`;
return;
}
renderCabecalho();
mostrarAba(params && params.get("aba") === "vencidos" ? "vencidos" : "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";
document.getElementById("conc-det-meta").innerHTML = `
<span>Data-base <strong>${pidConcData(detalhe.data_base)}</strong></span>
<span>Período do razão <strong>${periodo}</strong></span>
<span>Arquivo <strong>${pidConcEscape(detalhe.nome_arquivo || "—")}</strong></span>
<span>Processado por <strong>${pidConcEscape(detalhe.criado_por_nome || "—")}</strong> em ${pidConcDataHora(detalhe.criado_em)}</span>
${
detalhe.ultima_atualizacao
? `<span>Razão atualizado por <strong>${pidConcEscape(detalhe.ultima_atualizacao.usuario_nome || "—")}</strong> em ${pidConcDataHora(detalhe.ultima_atualizacao.criado_em)}</span>`
: ""
}`;
document.getElementById("conc-exportar-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/exportar-xlsx/`;
document.getElementById("conc-relatorio-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/relatorio/`;
}
function contasComAlerta(tipo) {
return detalhe.contas.filter((c) => c.alertas.some((a) => a.tipo === tipo));
}
/** Faixa de indicadores (3 valores + progresso da validação) e etiquetas
* dos apontamentos menores. Os números por situação ficam só nos chips,
* que já filtram (não duplicar em card). */
function renderCards() {
const contas = detalhe.contas;
const porStatus = {};
contas.forEach((c) => {
porStatus[c.status] = (porStatus[c.status] || 0) + 1;
});
const soma = (lista, campo) => lista.reduce((total, c) => total + (Number(c[campo]) || 0), 0);
const vencidas = contasVencidas();
const vencidasValidadas = vencidas.filter((c) => c.vencidos_validados).length;
const excedentes = contas.filter((c) => Number(c.total_excedente) > 0);
const validados = contas.filter((c) => c.validada).length;
const pct = contas.length ? Math.round((validados / contas.length) * 100) : 0;
const kpi = (rotulo, valor, detalheTxt, classe, alerta) => {
const tag = alerta ? "button" : "div";
const ativo = alerta && filtroAlerta === alerta ? " is-active" : "";
const atributos = alerta ? ` type="button" data-conc-alerta="${alerta}"` : "";
return `<${tag} class="conc-kpi ${classe}${ativo}"${atributos}>
<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"],
["pagamento_sem_titulo", "com pagamento não vinculado", "warning"],
["conta_transitoria", "conta transitória com saldo", "danger"],
]
.map(([tipo, texto, classe]) => [tipo, contasComAlerta(tipo).length, texto, classe])
.filter(([, quantidade]) => quantidade > 0)
.map(
([tipo, quantidade, texto, classe]) =>
`<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(
"Títulos em aberto",
pidConcMoeda(soma(contas, "total_em_aberto")),
soma(contas, "total_nao_vinculado")
? `sem pagamento vinculado · ${pidConcMoeda(soma(contas, "total_nao_vinculado"))} em pagamentos a vincular`
: "sem pagamento vinculado",
"",
""
)}
<button type="button" class="conc-kpi conc-kpi--warning${abaAtual === "vencidos" ? " is-active" : ""}" data-conc-abrir-vencidos>
<span class="conc-kpi__rotulo">Em aberto há mais de 3 meses</span>
<span class="conc-kpi__valor">${pidConcMoeda(soma(vencidas, "total_vencido"))}</span>
<span class="conc-kpi__detalhe">${vencidas.length} fornecedor(es) · ${vencidasValidadas} validado(s)</span>
</button>
${kpi("Pagamentos sem título", pidConcMoeda(soma(excedentes, "total_excedente")), `${excedentes.length} fornecedor(es) · divergência`, "conc-kpi--danger", "debito_excedente")}
<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"><span style="width:${pct}%"></span></div>
<span class="conc-kpi__detalhe">${contas.length - validados} ainda para revisar · ${pct}% concluído</span>
</div>
</div>
${apontamentos ? `<div class="conc-tags"><span class="conc-tags__rotulo">Apontamentos:</span>${apontamentos}</div>` : ""}`;
cardsEl.querySelectorAll("[data-conc-alerta]").forEach((el) => {
el.addEventListener("click", () => {
const alerta = el.dataset.concAlerta;
filtroAlerta = filtroAlerta === alerta ? "" : alerta;
filtroStatus = "";
mostrarAba("conciliacao");
renderCards();
renderContas();
});
});
cardsEl.querySelector("[data-conc-abrir-vencidos]").addEventListener("click", () => {
mostrarAba(abaAtual === "vencidos" ? "conciliacao" : "vencidos");
});
abaVencidosQtd.textContent = vencidas.length - vencidasValidadas;
filtroStatusBtns.forEach((btn) => {
const status = btn.dataset.concStatus;
const quantidade = status === "validada" ? validados : status ? porStatus[status] || 0 : contas.length;
btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} <span class="conc-chip__count">${quantidade}</span>`;
btn.classList.toggle("is-active", !filtroAlerta && filtroStatus === status);
});
mostrarValidadasQtd.textContent = validados;
}
filtroStatusBtns.forEach((btn) => {
btn.addEventListener("click", () => {
filtroStatus = btn.dataset.concStatus;
filtroAlerta = "";
renderCards();
renderContas();
});
});
contasBusca.addEventListener("input", pidConcAtrasado(() => detalhe && renderContas()));
mostrarValidadasInput.addEventListener("change", renderContas);
const sortValor = {
conta_numero: (c) => Number(c.conta_numero),
fornecedor: (c) => c.fornecedor,
saldo_inicial: (c) => Number(c.saldo_inicial),
saldo_final: (c) => Number(c.saldo_final),
total_vencido: (c) => Number(c.total_vencido),
status: (c) => PID_CONC_STATUS[c.status].rank,
validada: (c) => (c.validada ? 1 : 0),
};
sortHeaders.forEach((th) => {
th.addEventListener("click", () => {
const chave = th.dataset.sort;
sortDir = sortKey === chave ? sortDir * -1 : 1;
sortKey = chave;
atualizarSortHeaders();
renderContas();
});
});
function atualizarSortHeaders() {
sortHeaders.forEach((h) => {
h.classList.toggle("is-sort-asc", h.dataset.sort === sortKey && sortDir === 1);
h.classList.toggle("is-sort-desc", h.dataset.sort === sortKey && sortDir === -1);
});
}
/** A conta aparece com o filtro atual? Usado pela lista inteira e depois
* de cada ajuste numa conta (`aplicarRespostaConta`). */
function contaPassaFiltro(c) {
const termo = contasBusca.value.trim().toLowerCase();
// Validadas ficam fora por padrão (pedido do usuário): a lista mostra
// o que ainda falta revisar. O chip "Validados" as mostra sempre.
if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false;
if (filtroAlerta) {
const temAlerta =
filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta);
if (!temAlerta) return false;
} else if (filtroStatus === "validada") {
// validação é uma marcação à parte da situação calculada
if (!c.validada) return false;
} else if (filtroStatus && c.status !== filtroStatus) {
return false;
}
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
}
function contasFiltradas() {
let lista = detalhe.contas.filter(contaPassaFiltro);
if (sortKey) {
const extrator = sortValor[sortKey];
lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir);
}
return lista;
}
function renderContas() {
filtroAlertaEl.hidden = !filtroAlerta;
if (filtroAlerta) {
filtroAlertaEl.innerHTML = `Filtrando por: <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 =
!mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim()
? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los."
: "Nenhum fornecedor para este filtro.";
const fragmento = document.createDocumentFragment();
lista.forEach((conta) => montarLinhasConta(conta).forEach((tr) => fragmento.appendChild(tr)));
contasBody.appendChild(fragmento);
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);
const vencido = Number(conta.total_vencido);
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${Number(conta.saldo_inicial) === 0 ? " conc-valor--zero" : Number(conta.saldo_inicial) < 0 ? " conc-valor--devedor" : ""}">${pidConcSaldo(conta.saldo_inicial)}</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-num conc-valor${vencido ? " conc-valor--vencido" : " conc-valor--zero"}">${vencido ? pidConcMoeda(vencido) : "—"}</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 /></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;
try {
// sem `observacao` no corpo: o servidor mantém a já registrada
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, {
method: "POST",
body: { validada: check.checked },
});
aplicarRespostaConta(resposta);
} catch (erro) {
check.checked = !check.checked;
check.disabled = false;
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
}
});
tr.addEventListener("click", () => {
if (abertas.has(conta.id)) abertas.delete(conta.id);
else abertas.add(conta.id);
substituirConta(conta);
});
if (!aberta) return [tr];
const trDetalhe = document.createElement("tr");
trDetalhe.className = "conc-detail-row";
trDetalhe.dataset.contaDetalheId = conta.id;
const td = document.createElement("td");
td.colSpan = 8;
td.className = "conc-detail-cell";
td.appendChild(montarDetalheConta(conta));
trDetalhe.appendChild(td);
return [tr, trDetalhe];
}
/** Re-renderiza só as linhas de uma conta (a principal e o detalhe), no lugar. */
function substituirConta(conta) {
const antiga = contasBody.querySelector(`tr[data-conta-id="${conta.id}"]`);
if (!antiga) return;
const detalheAntigo = contasBody.querySelector(`tr[data-conta-detalhe-id="${conta.id}"]`);
const novas = montarLinhasConta(conta);
antiga.replaceWith(...novas);
if (detalheAntigo) detalheAntigo.remove();
}
function aplicarRespostaConta(resposta) {
const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id);
const eraValidada = indice >= 0 && detalhe.contas[indice].validada;
if (indice >= 0) detalhe.contas[indice] = resposta.conta;
// Conta que acabou de ser validada (checkbox, botão ou validação
// automática ao ficar conciliada) recolhe a expansão: o trabalho nela
// terminou (pedido do usuário).
if (!eraValidada && resposta.conta.validada) abertas.delete(resposta.conta.id);
detalhe.resumo = resposta.resumo;
const estado = estadoDe(resposta.conta.id);
estado.selecionados.clear();
renderCards();
// Conta que deixou de caber no filtro (validada com "Mostrar validadas"
// desmarcado, desmarcada no chip "Validados", mudou de situação com um
// chip de situação ativo) sai da lista na hora. Antes ela só era
// redesenhada no lugar e saía no próximo render da lista inteira
// (pedido do usuário: "tem um atraso, às vezes preciso atualizar").
if (contaPassaFiltro(resposta.conta)) substituirConta(resposta.conta);
else removerConta(resposta.conta.id);
}
/** Tira as linhas da conta da lista com um fade curto. */
function removerConta(contaId) {
const linhas = contasBody.querySelectorAll(`tr[data-conta-id="${contaId}"], tr[data-conta-detalhe-id="${contaId}"]`);
linhas.forEach((tr) => tr.classList.add("is-saindo"));
window.setTimeout(() => {
linhas.forEach((tr) => tr.remove());
if (!contasBody.querySelector("tr[data-conta-id]")) {
contasVazia.hidden = false;
contasVazia.textContent =
!mostrarValidadasInput.checked && !filtroAlerta && !filtroStatus && !contasBusca.value.trim()
? "Todos os fornecedores estão validados. Marque \"Mostrar validadas\" para vê-los."
: "Nenhum fornecedor para este filtro.";
}
}, 160);
}
function linhaLancamentoHtml(lanc, comCheckbox, selecionado) {
const situacao = lanc.situacao;
const valorAberto = pidConcValorAbertoHtml(situacao);
return `
<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));
const datasPendencia = conta.lancamentos
.filter((l) => l.vinculo_id === null || idsParciais.has(l.vinculo_id))
.map((l) => l.data);
const inicioPendencia = datasPendencia.length ? datasPendencia.reduce((a, b) => (a < b ? a : b)) : null;
const razao = inicioPendencia ? conta.lancamentos.filter((l) => l.data >= inicioPendencia) : [];
const qtdVinculadosRazao = razao.filter((l) => l.vinculo_id !== null).length;
const secaoPend = document.createElement("div");
secaoPend.innerHTML = `
<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)) 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);
return ultimoDoParcial.get(l.vinculo_id) === l.id ? linha + linhaSaldoParcialHtml(vinculoPorId.get(l.vinculo_id)) : linha;
})
.join("");
}
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) {
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">${lanc.saldo_apos === null ? "—" : pidConcSaldo(lanc.saldo_apos)}</td>`;
if (lanc.vinculo_id === null) {
const selecionado = estado.selecionados.has(lanc.id);
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">${pidConcValorAbertoHtml(lanc.situacao)}</td>
${pidConcSituacaoHtml(lanc.situacao)}
</tr>`;
}
const vinculo = vinculoPorId.get(lanc.vinculo_id);
const origem = vinculo && (vinculo.tipo === "manual" || vinculo.tipo === "parcial") ? "manual" : "automático";
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">Vinculado #${numeroVinculo.get(lanc.vinculo_id) || "?"}</span>
<span class="conc-vinculado__tipo">${pidConcEscape(vinculo ? vinculo.tipo_rotulo : "")} · ${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("; ");
const confirmado = await pidConfirm(
`Desfazer o vínculo #${numeroVinculo.get(vinculoId) || ""} (${lista})? Os lançamentos voltam para não conciliados.`,
{ titulo: "Desfazer vínculo", textoConfirmar: "Desfazer" }
);
if (!confirmado) return;
try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculoId}/`, { method: "DELETE" });
aplicarRespostaConta(resposta);
} catch (erro) {
pidAlert(erro.message, { titulo: "Não foi possível desfazer" });
}
}
// Linhas vinculadas: destacar os outros itens do mesmo vínculo ao passar
// o mouse, e "Desfazer" direto na linha.
secaoPend.querySelectorAll("tr[data-vinculo-id]").forEach((tr) => {
const grupo = () => secaoPend.querySelectorAll(`tr[data-vinculo-id="${tr.dataset.vinculoId}"]`);
tr.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-grupo")));
tr.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-grupo")));
});
// Sugestão: ao passar o mouse, destaca a própria linha e as do outro
// lado da sugestão (pedido do usuário, como o destaque do vínculo). Id
// negativo é o saldo de um vínculo parcial: destaca as linhas dele.
secaoPend.querySelectorAll("[data-conc-relacionados]").forEach((sugestaoEl) => {
const linha = sugestaoEl.closest("tr");
const ids = sugestaoEl.dataset.concRelacionados.split(",").filter(Boolean).map(Number);
const grupo = () => [
linha,
...ids.flatMap((id) =>
Array.from(secaoPend.querySelectorAll(id < 0 ? `tr[data-vinculo-id="${-id}"]` : `tr[data-lanc-id="${id}"]`))
),
];
sugestaoEl.addEventListener("mouseenter", () => grupo().forEach((g) => g.classList.add("is-sugestao-grupo")));
sugestaoEl.addEventListener("mouseleave", () => grupo().forEach((g) => g.classList.remove("is-sugestao-grupo")));
// Duplo clique seleciona a linha e as relacionadas; se já estavam todas
// selecionadas, desmarca (pedido do usuário). Só as 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 (String(window.getSelection ? window.getSelection() : "").trim()) return;
const check = tr.querySelector("[data-lanc-check]");
check.checked = !check.checked;
check.dispatchEvent(new Event("change"));
});
});
secaoPend.querySelectorAll("[data-lanc-check]").forEach((check) => {
check.addEventListener("change", () => {
const id = Number(check.dataset.lancCheck);
if (check.checked) estado.selecionados.add(id);
else estado.selecionados.delete(id);
check.closest("tr").classList.toggle("is-selecionado", check.checked);
atualizarSelecao();
});
});
// 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 efeito =
arredondada > 0
? `restarão ${pidConcMoeda(arredondada)} de diferença (em aberto, se passar da tolerância)`
: arredondada < 0
? `ficará pago a maior em ${pidConcMoeda(-arredondada)} (possível juros/multa)`
: "o vínculo fecha sem diferença";
const seguir = await pidConfirm(
`Adicionar ${novos.length} lançamento(s) ao vínculo #${numeroVinculo.get(vinculo.id) || ""}? Com eles, ${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) },
});
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 = pagamento parcial, o título continua em
// aberto pelo saldo.
const totalCreditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
const limite = Math.max(Number(detalhe.tolerancia_valor), (totalCreditos * Number(detalhe.tolerancia_percentual)) / 100);
if (diferenca > limite) {
const seguir = await pidConfirm(
`Os débitos selecionados pagam ${pidConcMoeda(totalCreditos - diferenca)} de ${pidConcMoeda(totalCreditos)}. Vincular como pagamento parcial? O título continua em aberto com ${pidConcMoeda(diferenca)}.`,
{ titulo: "Pagamento parcial", textoConfirmar: "Vincular" }
);
if (!seguir) return;
} else if (diferenca !== 0) {
const natureza = diferenca < 0 ? "pago a maior (possível juros/multa)" : "pago a menor (possível desconto)";
const seguir = await pidConfirm(
`Os lançamentos selecionados têm diferença de ${pidConcMoeda(Math.abs(diferenca))} (${natureza}). Vincular mesmo assim? O vínculo fica sinalizado nos alertas da conta.`,
{ titulo: "Vínculo com diferença", textoConfirmar: "Vincular" }
);
if (!seguir) return;
}
vincularBtn.disabled = true;
try {
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/vincular/`, {
method: "POST",
body: { lancamentos: selecionados.map((l) => l.id) },
});
aplicarRespostaConta(resposta);
} catch (erro) {
vincularBtn.disabled = false;
pidAlert(erro.message, { titulo: "Não foi possível vincular" });
}
});
// ---- Vinculados ----
function montarBlocoVinculo(vinculo) {
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
const manual = vinculo.tipo === "manual" || vinculo.tipo === "parcial";
const origem = manual ? `manual, por ${pidConcEscape(vinculo.criado_por_nome || "—")}` : "automático";
const diferenca = Number(vinculo.diferenca);
let detalheDiferenca = "";
if (vinculo.tipo === "parcial") {
const saldo = vinculo.situacao;
detalheDiferenca = ` · restam <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; });
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 renderContas();
}
abaBtns.forEach((btn) => btn.addEventListener("click", () => mostrarAba(btn.dataset.concAba)));
function contasVencidas() {
return detalhe.contas.filter((c) => Number(c.total_vencido) > 0);
}
/** Títulos da conta em aberto há mais de 3 meses, do mais antigo ao mais
* recente: lançamentos sem vínculo e saldos de pagamento parcial. */
function titulosVencidos(conta) {
const itens = conta.lancamentos
.filter((l) => l.situacao && l.situacao.categoria === "vencido")
.map((l) => ({ lanc: l, valor: l.situacao.valor_em_aberto }));
conta.vinculos.forEach((v) => {
if (!v.situacao || v.situacao.categoria !== "vencido") return;
const titulo = conta.lancamentos
.filter((l) => l.vinculo_id === v.id && l.natureza === "C")
.sort((a, b) => a.data.localeCompare(b.data))[0];
if (titulo) {
itens.push({ lanc: { ...titulo, historico: `${titulo.historico} (saldo do pagamento parcial)` }, valor: v.situacao.valor_em_aberto });
}
});
return itens.sort((a, b) => a.lanc.data.localeCompare(b.lanc.data));
}
function diasEmAberto(iso) {
const dias = Math.round((Date.parse(detalhe.data_base) - Date.parse(iso.slice(0, 10))) / 86400000);
return `${dias} dias`;
}
function textoValidacaoVencidos(conta) {
if (!conta.vencidos_validados) return "";
return `Validado por ${conta.vencidos_validados_por_nome || "—"} em ${pidConcDataHora(conta.vencidos_validados_em)}`;
}
function vencPassaFiltro(c) {
if (Number(c.total_vencido) <= 0) return false;
if (vencFiltro === "pendentes" && c.vencidos_validados) return false;
if (vencFiltro === "validados" && !c.vencidos_validados) return false;
const termo = vencBusca.value.trim().toLowerCase();
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
}
const vencSortValor = {
conta_numero: (c) => Number(c.conta_numero),
fornecedor: (c) => c.fornecedor,
qtd_vencidos: (c) => c.qtd_vencidos,
mais_antigo: (c) => (titulosVencidos(c)[0] || { lanc: { data: "" } }).lanc.data,
total_vencido: (c) => Number(c.total_vencido),
vencidos_validados: (c) => (c.vencidos_validados ? 1 : 0),
};
vencSortHeaders.forEach((th) => {
th.addEventListener("click", () => {
const chave = th.dataset.vencSort;
vencSortDir = vencSortKey === chave ? vencSortDir * -1 : 1;
vencSortKey = chave;
renderVencidos();
});
});
vencFiltroBtns.forEach((btn) => {
btn.addEventListener("click", () => {
vencFiltro = btn.dataset.concVenc;
renderVencidos();
});
});
vencBusca.addEventListener("input", 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();
}
});