1882 lines
93 KiB
JavaScript
1882 lines
93 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;
|
|
}
|
|
|
|
/** 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 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";
|
|
periodoFimInput.value = pidConcRazaoAtePadrao();
|
|
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.";
|
|
return;
|
|
}
|
|
if (!arquivo) {
|
|
formErroEl.textContent = "Selecione o arquivo do razão.";
|
|
return;
|
|
}
|
|
if (!periodoFimInput.value) {
|
|
formErroEl.textContent = "Informe até quando o razão foi exportado (\"Razão até\").";
|
|
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);
|
|
} 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>
|
|
<button type="button" class="btn-danger-outline conc-btn-sm" data-excluir>Excluir</button>
|
|
</span>
|
|
</td>`;
|
|
tr.addEventListener("click", (evento) => {
|
|
if (evento.target.closest("[data-excluir], [data-atualizar]")) return;
|
|
abrirDetalhe(item.id);
|
|
});
|
|
tr.querySelector("[data-atualizar]").addEventListener("click", () => abrirAtualizar(item));
|
|
tr.querySelector("[data-excluir]").addEventListener("click", async () => {
|
|
const confirmado = await pidConfirm(
|
|
`Excluir a conciliação de ${item.codigo_empresa} - ${item.nome_empresa || ""} (data-base ${pidConcData(item.data_base)})? Os vínculos manuais e validações feitos nela também serão excluídos.`,
|
|
{ titulo: "Excluir conciliação", textoConfirmar: "Excluir", perigoso: true }
|
|
);
|
|
if (!confirmado) return;
|
|
try {
|
|
await pidApiRequest(`/conciliacoes-fornecedores/${item.id}/`, { method: "DELETE" });
|
|
await carregarHistorico();
|
|
} catch (erro) {
|
|
pidAlert(erro.message, { titulo: "Não foi possível excluir" });
|
|
}
|
|
});
|
|
listaBody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
listaBusca.addEventListener("input", renderHistorico);
|
|
|
|
async function carregarHistorico() {
|
|
try {
|
|
historico = await pidApiRequest("/conciliacoes-fornecedores/");
|
|
} catch (erro) {
|
|
historico = [];
|
|
listaVazia.hidden = false;
|
|
listaVazia.textContent = erro.message;
|
|
return;
|
|
}
|
|
renderHistorico();
|
|
}
|
|
|
|
// ======================================================================
|
|
// 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 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());
|
|
atualizarPeriodoFim.value =
|
|
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;
|
|
}
|
|
if (!atualizarPeriodoFim.value) {
|
|
atualizarErro.textContent = "Informe até quando o razão foi exportado (\"Razão até\").";
|
|
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) {
|
|
await abrirDetalhe(alvo.id);
|
|
} 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();
|
|
|
|
function estadoDe(contaId) {
|
|
if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false });
|
|
return estadoConta.get(contaId);
|
|
}
|
|
|
|
document.getElementById("conc-voltar-btn").addEventListener("click", () => {
|
|
history.replaceState(null, "", window.location.pathname);
|
|
mostrarLista();
|
|
});
|
|
|
|
function mostrarLista() {
|
|
detalhe = null;
|
|
detailView.hidden = true;
|
|
listView.hidden = false;
|
|
carregarHistorico();
|
|
}
|
|
|
|
async function abrirDetalhe(id) {
|
|
listView.hidden = true;
|
|
detailView.hidden = false;
|
|
cardsEl.innerHTML = "";
|
|
contasBody.innerHTML = '<tr><td colspan="8" class="conc-empty">Carregando...</td></tr>';
|
|
history.replaceState(null, "", `#conciliacao-${id}`);
|
|
abertas.clear();
|
|
estadoConta.clear();
|
|
vencAbertas.clear();
|
|
vencFiltro = "pendentes";
|
|
vencBusca.value = "";
|
|
detalhe = null; // mostrarAba não redesenha nada até o detalhe novo chegar
|
|
mostrarAba("conciliacao");
|
|
filtroStatus = "";
|
|
filtroAlerta = "";
|
|
contasBusca.value = "";
|
|
mostrarValidadasInput.checked = false;
|
|
sortKey = SORT_PADRAO;
|
|
sortDir = 1;
|
|
atualizarSortHeaders();
|
|
try {
|
|
detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`);
|
|
} catch (erro) {
|
|
contasBody.innerHTML = `<tr><td colspan="8" class="conc-empty">${pidConcEscape(erro.message)}</td></tr>`;
|
|
return;
|
|
}
|
|
renderCabecalho();
|
|
renderCards();
|
|
renderContas();
|
|
}
|
|
|
|
function renderCabecalho() {
|
|
document.getElementById("conc-det-codigo").textContent = `EMPRESA ${detalhe.codigo_empresa}`;
|
|
document.getElementById("conc-det-nome").textContent = detalhe.nome_empresa || "Empresa não encontrada no Questor";
|
|
const periodo = detalhe.periodo_inicio ? `${pidConcData(detalhe.periodo_inicio)} a ${pidConcData(detalhe.periodo_fim)}` : "sem lançamentos";
|
|
document.getElementById("conc-det-meta").innerHTML = `
|
|
<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", 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);
|
|
}
|
|
|
|
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" title="${pidConcEscape(pidConcTextoValidacao(conta) || "Marcar como validada")}" /></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" title="Marcar todos" /></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;
|
|
}
|
|
painel.querySelectorAll("[data-adicionar]").forEach((btn) => {
|
|
btn.disabled = lancamentos.length === 0;
|
|
});
|
|
if (todosCheck) {
|
|
todosCheck.checked = lancamentos.length > 0 && lancamentos.length === pendentes.length;
|
|
todosCheck.indeterminate = lancamentos.length > 0 && lancamentos.length < pendentes.length;
|
|
todosCheck.title = 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 ? '<button type="button" class="btn-outline conc-btn-sm" data-adicionar disabled title="Marque lançamentos na lista de não conciliados">Adicionar selecionados</button>' : ""}
|
|
<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(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]");
|
|
async function salvarValidacao(validada) {
|
|
try {
|
|
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar/`, {
|
|
method: "POST",
|
|
body: { validada, observacao: observacaoEl.value },
|
|
});
|
|
aplicarRespostaConta(resposta);
|
|
} catch (erro) {
|
|
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
|
|
}
|
|
}
|
|
validacao.querySelector("[data-salvar-obs]").addEventListener("click", () => salvarValidacao(conta.validada));
|
|
validacao.querySelector("[data-validar]").addEventListener("click", () => salvarValidacao(!conta.validada));
|
|
validacao.querySelector("[data-recolher]").addEventListener("click", () => {
|
|
abertas.delete(conta.id);
|
|
substituirConta(conta);
|
|
// a expansão pode ser longa: volta a tela para a linha do fornecedor
|
|
const linha = contasBody.querySelector(`tr[data-conta-id="${conta.id}"]`);
|
|
if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" });
|
|
});
|
|
painel.appendChild(validacao);
|
|
|
|
return painel;
|
|
}
|
|
|
|
// ======================================================================
|
|
// Tela "Em aberto há mais de 3 meses"
|
|
// ======================================================================
|
|
// Pedido do usuário: título antigo sem pagamento não é bloqueio para a
|
|
// análise (não deixa mais a conta em "Atenção"), mas precisa de revisão
|
|
// própria. Esta aba lista os fornecedores com esses títulos, com
|
|
// validação e observação por fornecedor (`validar-vencidos/`), separadas
|
|
// da validação da conciliação.
|
|
const abaBtns = Array.from(document.querySelectorAll("[data-conc-aba]"));
|
|
const abaPaineis = Array.from(document.querySelectorAll("[data-conc-painel]"));
|
|
const abaVencidosQtd = document.getElementById("conc-aba-vencidos-qtd");
|
|
const vencBody = document.getElementById("conc-venc-body");
|
|
const vencVazia = document.getElementById("conc-venc-vazia");
|
|
const vencBusca = document.getElementById("conc-venc-busca");
|
|
const vencFiltroBtns = Array.from(document.querySelectorAll("[data-conc-venc]"));
|
|
const vencSortHeaders = Array.from(document.querySelectorAll("#conc-venc-table th[data-venc-sort]"));
|
|
let abaAtual = "conciliacao";
|
|
let vencFiltro = "pendentes";
|
|
let vencSortKey = "total_vencido";
|
|
let vencSortDir = -1;
|
|
const vencAbertas = new Set();
|
|
|
|
function mostrarAba(aba) {
|
|
abaAtual = aba;
|
|
abaBtns.forEach((btn) => btn.classList.toggle("is-active", btn.dataset.concAba === aba));
|
|
abaPaineis.forEach((painel) => { painel.hidden = painel.dataset.concPainel !== aba; });
|
|
if (!detalhe) return;
|
|
renderCards();
|
|
if (aba === "vencidos") renderVencidos();
|
|
else renderContas();
|
|
}
|
|
|
|
abaBtns.forEach((btn) => btn.addEventListener("click", () => mostrarAba(btn.dataset.concAba)));
|
|
|
|
function contasVencidas() {
|
|
return detalhe.contas.filter((c) => Number(c.total_vencido) > 0);
|
|
}
|
|
|
|
/** Títulos da conta em aberto há mais de 3 meses, do mais antigo ao mais
|
|
* recente: lançamentos sem vínculo e saldos de pagamento parcial. */
|
|
function titulosVencidos(conta) {
|
|
const itens = conta.lancamentos
|
|
.filter((l) => l.situacao && l.situacao.categoria === "vencido")
|
|
.map((l) => ({ lanc: l, valor: l.situacao.valor_em_aberto }));
|
|
conta.vinculos.forEach((v) => {
|
|
if (!v.situacao || v.situacao.categoria !== "vencido") return;
|
|
const titulo = conta.lancamentos
|
|
.filter((l) => l.vinculo_id === v.id && l.natureza === "C")
|
|
.sort((a, b) => a.data.localeCompare(b.data))[0];
|
|
if (titulo) {
|
|
itens.push({ lanc: { ...titulo, historico: `${titulo.historico} (saldo do pagamento parcial)` }, valor: v.situacao.valor_em_aberto });
|
|
}
|
|
});
|
|
return itens.sort((a, b) => a.lanc.data.localeCompare(b.lanc.data));
|
|
}
|
|
|
|
function diasEmAberto(iso) {
|
|
const dias = Math.round((Date.parse(detalhe.data_base) - Date.parse(iso.slice(0, 10))) / 86400000);
|
|
return `${dias} dias`;
|
|
}
|
|
|
|
function textoValidacaoVencidos(conta) {
|
|
if (!conta.vencidos_validados) return "";
|
|
return `Validado por ${conta.vencidos_validados_por_nome || "—"} em ${pidConcDataHora(conta.vencidos_validados_em)}`;
|
|
}
|
|
|
|
function vencPassaFiltro(c) {
|
|
if (Number(c.total_vencido) <= 0) return false;
|
|
if (vencFiltro === "pendentes" && c.vencidos_validados) return false;
|
|
if (vencFiltro === "validados" && !c.vencidos_validados) return false;
|
|
const termo = vencBusca.value.trim().toLowerCase();
|
|
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
|
|
}
|
|
|
|
const vencSortValor = {
|
|
conta_numero: (c) => Number(c.conta_numero),
|
|
fornecedor: (c) => c.fornecedor,
|
|
qtd_vencidos: (c) => c.qtd_vencidos,
|
|
mais_antigo: (c) => (titulosVencidos(c)[0] || { lanc: { data: "" } }).lanc.data,
|
|
total_vencido: (c) => Number(c.total_vencido),
|
|
vencidos_validados: (c) => (c.vencidos_validados ? 1 : 0),
|
|
};
|
|
|
|
vencSortHeaders.forEach((th) => {
|
|
th.addEventListener("click", () => {
|
|
const chave = th.dataset.vencSort;
|
|
vencSortDir = vencSortKey === chave ? vencSortDir * -1 : 1;
|
|
vencSortKey = chave;
|
|
renderVencidos();
|
|
});
|
|
});
|
|
vencFiltroBtns.forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
vencFiltro = btn.dataset.concVenc;
|
|
renderVencidos();
|
|
});
|
|
});
|
|
vencBusca.addEventListener("input", renderVencidos);
|
|
|
|
function atualizarContagemVencidos() {
|
|
const todas = contasVencidas();
|
|
const validadas = todas.filter((c) => c.vencidos_validados).length;
|
|
const contagem = { pendentes: todas.length - validadas, validados: validadas, "": todas.length };
|
|
vencFiltroBtns.forEach((btn) => {
|
|
const chave = btn.dataset.concVenc;
|
|
btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} <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);
|
|
}
|
|
|
|
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(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]");
|
|
const salvar = async (validada) => {
|
|
try {
|
|
await salvarVencidos(conta, validada, observacaoEl.value);
|
|
} catch (erro) {
|
|
pidAlert(erro.message, { titulo: "Não foi possível salvar" });
|
|
}
|
|
};
|
|
painel.querySelector("[data-venc-salvar-obs]").addEventListener("click", () => salvar(conta.vencidos_validados));
|
|
painel.querySelector("[data-venc-validar]").addEventListener("click", () => salvar(!conta.vencidos_validados));
|
|
painel.querySelector("[data-venc-recolher]").addEventListener("click", () => {
|
|
vencAbertas.delete(conta.id);
|
|
substituirVencidos(conta);
|
|
const linha = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`);
|
|
if (linha && linha.getBoundingClientRect().top < 120) linha.scrollIntoView({ block: "center" });
|
|
});
|
|
return painel;
|
|
}
|
|
|
|
function substituirVencidos(conta) {
|
|
const antiga = vencBody.querySelector(`tr[data-venc-conta-id="${conta.id}"]`);
|
|
if (!antiga) return;
|
|
const detalheAntigo = vencBody.querySelector(`tr[data-venc-detalhe-id="${conta.id}"]`);
|
|
antiga.replaceWith(...montarLinhasVencidos(conta));
|
|
if (detalheAntigo) detalheAntigo.remove();
|
|
}
|
|
|
|
function removerVencidos(contaId) {
|
|
const linhas = vencBody.querySelectorAll(`tr[data-venc-conta-id="${contaId}"], tr[data-venc-detalhe-id="${contaId}"]`);
|
|
linhas.forEach((tr) => tr.classList.add("is-saindo"));
|
|
window.setTimeout(() => {
|
|
linhas.forEach((tr) => tr.remove());
|
|
if (!vencBody.querySelector("tr[data-venc-conta-id]")) {
|
|
vencVazia.hidden = false;
|
|
vencVazia.textContent = mensagemVencidosVazia(contasVencidas().length);
|
|
}
|
|
}, 160);
|
|
}
|
|
|
|
/** Grava a validação/observação dos títulos antigos e atualiza a linha
|
|
* (sai da lista com fade se deixar de caber no filtro, como na conciliação). */
|
|
async function salvarVencidos(conta, validada, observacao) {
|
|
const corpo = { validada };
|
|
if (observacao !== undefined) corpo.observacao = observacao;
|
|
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/validar-vencidos/`, {
|
|
method: "POST",
|
|
body: corpo,
|
|
});
|
|
const indice = detalhe.contas.findIndex((c) => c.id === resposta.conta.id);
|
|
const eraValidada = indice >= 0 && detalhe.contas[indice].vencidos_validados;
|
|
if (indice >= 0) detalhe.contas[indice] = resposta.conta;
|
|
if (!eraValidada && resposta.conta.vencidos_validados) vencAbertas.delete(resposta.conta.id);
|
|
detalhe.resumo = resposta.resumo;
|
|
renderCards();
|
|
atualizarContagemVencidos();
|
|
if (vencPassaFiltro(resposta.conta)) substituirVencidos(resposta.conta);
|
|
else removerVencidos(resposta.conta.id);
|
|
}
|
|
|
|
// ======================================================================
|
|
// Início
|
|
// ======================================================================
|
|
const hash = window.location.hash.match(/^#conciliacao-(\d+)$/);
|
|
if (hash) {
|
|
abrirDetalhe(Number(hash[1]));
|
|
} else {
|
|
carregarHistorico();
|
|
}
|
|
});
|