1039 lines
50 KiB
JavaScript
1039 lines
50 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" });
|
|
|
|
const PID_CONC_STATUS = {
|
|
divergente: { rotulo: "Divergente", pill: "status-pill--danger", rank: 0 },
|
|
atencao: { rotulo: "Atenção", pill: "status-pill--warning", rank: 1 },
|
|
pendente: { rotulo: "Pendente de pagamento", pill: "status-pill--neutral", rank: 2 },
|
|
conciliada: { rotulo: "Conciliada", pill: "status-pill--ok", rank: 3 },
|
|
};
|
|
|
|
const PID_CONC_ROTULO_ALERTA = {
|
|
credito_vencido: "Títulos em aberto há mais de 3 meses",
|
|
debito_excedente: "Pagamentos sem título em aberto",
|
|
pagamento_sem_titulo: "Pagamentos sem título de mesmo valor",
|
|
diferenca_vinculo: "Diferenças de juros/desconto",
|
|
conta_transitoria: "Conta transitória com saldo",
|
|
saldo_devedor: "Saldo devedor",
|
|
saldo_divergente_arquivo: "Saldo diferente do arquivo",
|
|
};
|
|
|
|
const PID_CONC_SEVERIDADE = { alta: "Alta", media: "Média", baixa: "Baixa" };
|
|
|
|
function pidConcEscape(texto) {
|
|
if (texto == null) return "";
|
|
const div = document.createElement("div");
|
|
div.textContent = String(texto);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function pidConcMoeda(valor) {
|
|
return PID_CONC_MOEDA.format(Number(valor) || 0);
|
|
}
|
|
|
|
/** Saldo com o lado ("C" credor positivo, "D" devedor negativo), como o Questor imprime. */
|
|
function pidConcSaldo(valor) {
|
|
const numero = Number(valor) || 0;
|
|
if (numero === 0) return pidConcMoeda(0);
|
|
return `${pidConcMoeda(Math.abs(numero))} ${numero > 0 ? "C" : "D"}`;
|
|
}
|
|
|
|
function pidConcData(iso) {
|
|
if (!iso) return "—";
|
|
const [ano, mes, dia] = iso.slice(0, 10).split("-");
|
|
return `${dia}/${mes}/${ano}`;
|
|
}
|
|
|
|
function pidConcDataHora(iso) {
|
|
if (!iso) return "—";
|
|
const data = new Date(iso);
|
|
if (Number.isNaN(data.getTime())) return "—";
|
|
return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
function pidConcCompara(a, b) {
|
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
return String(a ?? "").localeCompare(String(b ?? ""), "pt-BR", { sensitivity: "base", numeric: true });
|
|
}
|
|
|
|
/** Valor em aberto de um item: sempre o real (só vínculo muda valor). */
|
|
function pidConcValorAbertoHtml(situacao) {
|
|
if (!situacao || !Number(situacao.valor_em_aberto)) return "—";
|
|
return pidConcMoeda(situacao.valor_em_aberto);
|
|
}
|
|
|
|
/** Célula de situação: o fato primeiro; a dedução da ferramenta, se houver,
|
|
* numa linha à parte marcada como sugestão (nunca misturada ao fato). */
|
|
function pidConcSituacaoHtml(situacao) {
|
|
if (!situacao) return '<td class="conc-situacao"></td>';
|
|
const sugestao = situacao.sugestao
|
|
? `<span class="conc-sugestao"><span class="conc-sugestao-tag">Sugestão</span>${pidConcEscape(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.pendente;
|
|
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 formErroEl = document.getElementById("conc-form-erro");
|
|
const processarBtn = document.getElementById("conc-form-processar-btn");
|
|
const listaBody = document.getElementById("conc-lista-body");
|
|
const listaVazia = document.getElementById("conc-lista-vazia");
|
|
const listaBusca = document.getElementById("conc-lista-busca");
|
|
|
|
let historico = [];
|
|
let nomeEmpresaResolvido = null; // null = não consultado/não encontrado
|
|
let timerNomeEmpresa = null;
|
|
let consultaNomeAtual = 0;
|
|
|
|
function limparFormulario() {
|
|
codigoInput.value = "";
|
|
empresaEl.textContent = "";
|
|
empresaEl.className = "conc-form__empresa";
|
|
arquivoInput.value = "";
|
|
arquivoNomeEl.textContent = "Nenhum arquivo selecionado";
|
|
formErroEl.textContent = "";
|
|
nomeEmpresaResolvido = null;
|
|
}
|
|
|
|
novaBtn.addEventListener("click", () => {
|
|
const abrir = formEl.hidden;
|
|
formEl.hidden = !abrir;
|
|
if (abrir) {
|
|
limparFormulario();
|
|
codigoInput.focus();
|
|
}
|
|
});
|
|
document.getElementById("conc-form-cancelar-btn").addEventListener("click", () => {
|
|
formEl.hidden = true;
|
|
});
|
|
|
|
arquivoInput.addEventListener("change", () => {
|
|
const arquivo = arquivoInput.files[0];
|
|
arquivoNomeEl.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
|
|
// O nome do arquivo costuma começar pelo código da empresa ("2017 - ..."):
|
|
// sugere, sem sobrescrever o que já foi digitado.
|
|
const prefixo = arquivo && arquivo.name.match(/^(\d+)\s*[-_ ]/);
|
|
if (prefixo && !codigoInput.value.trim()) {
|
|
codigoInput.value = prefixo[1];
|
|
buscarNomeEmpresa();
|
|
}
|
|
});
|
|
|
|
async function buscarNomeEmpresa() {
|
|
const codigo = codigoInput.value.trim();
|
|
nomeEmpresaResolvido = null;
|
|
if (!codigo) {
|
|
empresaEl.textContent = "";
|
|
empresaEl.className = "conc-form__empresa";
|
|
return;
|
|
}
|
|
if (!/^\d+$/.test(codigo)) {
|
|
empresaEl.textContent = "Informe apenas números.";
|
|
empresaEl.className = "conc-form__empresa is-erro";
|
|
return;
|
|
}
|
|
const consulta = ++consultaNomeAtual;
|
|
empresaEl.textContent = "Buscando no Questor...";
|
|
empresaEl.className = "conc-form__empresa is-carregando";
|
|
try {
|
|
const dados = await pidApiRequest(`/conciliacoes-fornecedores/nome-empresa/?codigo_empresa=${encodeURIComponent(codigo)}`);
|
|
if (consulta !== consultaNomeAtual) return;
|
|
nomeEmpresaResolvido = dados.nome_empresa;
|
|
empresaEl.textContent = dados.nome_empresa || "Empresa não encontrada no Questor.";
|
|
empresaEl.className = `conc-form__empresa${dados.nome_empresa ? "" : " is-erro"}`;
|
|
} catch (erro) {
|
|
if (consulta !== consultaNomeAtual) return;
|
|
empresaEl.textContent = erro.message;
|
|
empresaEl.className = "conc-form__empresa is-erro";
|
|
}
|
|
}
|
|
|
|
codigoInput.addEventListener("input", () => {
|
|
clearTimeout(timerNomeEmpresa);
|
|
timerNomeEmpresa = setTimeout(buscarNomeEmpresa, 450);
|
|
});
|
|
|
|
processarBtn.addEventListener("click", async () => {
|
|
formErroEl.textContent = "";
|
|
const codigo = codigoInput.value.trim();
|
|
const arquivo = arquivoInput.files[0];
|
|
if (!codigo) {
|
|
formErroEl.textContent = "Informe o código da empresa.";
|
|
return;
|
|
}
|
|
if (!arquivo) {
|
|
formErroEl.textContent = "Selecione o arquivo do razão.";
|
|
return;
|
|
}
|
|
clearTimeout(timerNomeEmpresa);
|
|
if (nomeEmpresaResolvido === null) await buscarNomeEmpresa();
|
|
if (!nomeEmpresaResolvido) {
|
|
const seguir = await pidConfirm(
|
|
`A empresa ${codigo} não foi encontrada no Questor (ou o Questor está inacessível agora). Processar assim mesmo?`,
|
|
{ titulo: "Empresa não encontrada", textoConfirmar: "Processar" }
|
|
);
|
|
if (!seguir) return;
|
|
}
|
|
|
|
const dados = new FormData();
|
|
dados.append("codigo_empresa", codigo);
|
|
dados.append("arquivo", arquivo);
|
|
|
|
processarBtn.disabled = true;
|
|
processarBtn.textContent = "Processando...";
|
|
try {
|
|
const criada = await pidApiRequest("/conciliacoes-fornecedores/", { method: "POST", body: dados });
|
|
formEl.hidden = true;
|
|
await carregarHistorico();
|
|
abrirDetalhe(criada.id);
|
|
} catch (erro) {
|
|
formErroEl.textContent = erro.message;
|
|
} finally {
|
|
processarBtn.disabled = false;
|
|
processarBtn.textContent = "Processar";
|
|
}
|
|
});
|
|
|
|
function renderHistorico() {
|
|
const termo = listaBusca.value.trim().toLowerCase();
|
|
const itens = historico.filter(
|
|
(item) => !termo || `${item.codigo_empresa} ${item.nome_empresa}`.toLowerCase().includes(termo)
|
|
);
|
|
listaBody.innerHTML = "";
|
|
listaVazia.hidden = itens.length > 0;
|
|
listaVazia.textContent = historico.length ? "Nenhuma conciliação para esta busca." : "Nenhuma conciliação realizada ainda.";
|
|
itens.forEach((item) => {
|
|
const resumo = item.resumo || {};
|
|
const porStatus = resumo.por_status || {};
|
|
const conciliados = porStatus.conciliada || 0;
|
|
const periodo = item.periodo_inicio ? `${pidConcData(item.periodo_inicio)} a ${pidConcData(item.periodo_fim)}` : "—";
|
|
const tr = document.createElement("tr");
|
|
tr.className = "conc-row";
|
|
tr.innerHTML = `
|
|
<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)}</span></td>
|
|
<td class="conc-num"><button type="button" class="btn-danger-outline conc-btn-sm" data-excluir>Excluir</button></td>`;
|
|
tr.addEventListener("click", (evento) => {
|
|
if (evento.target.closest("[data-excluir]")) return;
|
|
abrirDetalhe(item.id);
|
|
});
|
|
tr.querySelector("[data-excluir]").addEventListener("click", async () => {
|
|
const confirmado = await pidConfirm(
|
|
`Excluir a conciliação de ${item.codigo_empresa} - ${item.nome_empresa || ""} (data-base ${pidConcData(item.data_base)})? Os vínculos manuais e validações feitos nela também serão excluídos.`,
|
|
{ titulo: "Excluir conciliação", textoConfirmar: "Excluir", perigoso: true }
|
|
);
|
|
if (!confirmado) return;
|
|
try {
|
|
await pidApiRequest(`/conciliacoes-fornecedores/${item.id}/`, { method: "DELETE" });
|
|
await carregarHistorico();
|
|
} catch (erro) {
|
|
pidAlert(erro.message, { titulo: "Não foi possível excluir" });
|
|
}
|
|
});
|
|
listaBody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
listaBusca.addEventListener("input", renderHistorico);
|
|
|
|
async function carregarHistorico() {
|
|
try {
|
|
historico = await pidApiRequest("/conciliacoes-fornecedores/");
|
|
} catch (erro) {
|
|
historico = [];
|
|
listaVazia.hidden = false;
|
|
listaVazia.textContent = erro.message;
|
|
return;
|
|
}
|
|
renderHistorico();
|
|
}
|
|
|
|
// ======================================================================
|
|
// Detalhe
|
|
// ======================================================================
|
|
const cardsEl = document.getElementById("conc-cards");
|
|
const contasBody = document.getElementById("conc-contas-body");
|
|
const contasVazia = document.getElementById("conc-contas-vazia");
|
|
const contasBusca = document.getElementById("conc-contas-busca");
|
|
const filtroStatusBtns = Array.from(document.querySelectorAll("[data-conc-status]"));
|
|
const filtroAlertaEl = document.getElementById("conc-filtro-alerta");
|
|
const sortHeaders = Array.from(document.querySelectorAll("#conc-contas-table th[data-sort]"));
|
|
const mostrarValidadasInput = document.getElementById("conc-mostrar-validadas");
|
|
const mostrarValidadasQtd = document.getElementById("conc-mostrar-validadas-qtd");
|
|
|
|
let detalhe = null;
|
|
let filtroStatus = "";
|
|
let filtroAlerta = "";
|
|
// Padrão: por situação, divergentes primeiro (pedido do usuário).
|
|
const SORT_PADRAO = "status";
|
|
let sortKey = SORT_PADRAO;
|
|
let sortDir = 1;
|
|
const abertas = new Set();
|
|
// estado de cada conta expandida: lançamentos selecionados e se mostra os já vinculados
|
|
const estadoConta = new Map();
|
|
|
|
function estadoDe(contaId) {
|
|
if (!estadoConta.has(contaId)) estadoConta.set(contaId, { selecionados: new Set(), mostrarVinculados: false, ocultarConciliados: false });
|
|
return estadoConta.get(contaId);
|
|
}
|
|
|
|
document.getElementById("conc-voltar-btn").addEventListener("click", () => {
|
|
history.replaceState(null, "", window.location.pathname);
|
|
mostrarLista();
|
|
});
|
|
|
|
function mostrarLista() {
|
|
detalhe = null;
|
|
detailView.hidden = true;
|
|
listView.hidden = false;
|
|
carregarHistorico();
|
|
}
|
|
|
|
async function abrirDetalhe(id) {
|
|
listView.hidden = true;
|
|
detailView.hidden = false;
|
|
cardsEl.innerHTML = "";
|
|
contasBody.innerHTML = '<tr><td colspan="8" class="conc-empty">Carregando...</td></tr>';
|
|
history.replaceState(null, "", `#conciliacao-${id}`);
|
|
abertas.clear();
|
|
estadoConta.clear();
|
|
filtroStatus = "";
|
|
filtroAlerta = "";
|
|
contasBusca.value = "";
|
|
mostrarValidadasInput.checked = false;
|
|
sortKey = SORT_PADRAO;
|
|
sortDir = 1;
|
|
atualizarSortHeaders();
|
|
try {
|
|
detalhe = await pidApiRequest(`/conciliacoes-fornecedores/${id}/`);
|
|
} catch (erro) {
|
|
contasBody.innerHTML = `<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>`;
|
|
document.getElementById("conc-exportar-btn").href = `${PID_API_BASE}/conciliacoes-fornecedores/${detalhe.id}/exportar-xlsx/`;
|
|
}
|
|
|
|
function contasComAlerta(tipo) {
|
|
return detalhe.contas.filter((c) => c.alertas.some((a) => a.tipo === tipo));
|
|
}
|
|
|
|
/** Faixa de indicadores (3 valores + progresso da validação) e etiquetas
|
|
* dos apontamentos menores. Os números por situação ficam só nos chips,
|
|
* que já filtram (não duplicar em card). */
|
|
function renderCards() {
|
|
const contas = detalhe.contas;
|
|
const porStatus = {};
|
|
contas.forEach((c) => {
|
|
porStatus[c.status] = (porStatus[c.status] || 0) + 1;
|
|
});
|
|
const soma = (lista, campo) => lista.reduce((total, c) => total + (Number(c[campo]) || 0), 0);
|
|
const vencidas = contasComAlerta("credito_vencido");
|
|
const excedentes = contas.filter((c) => Number(c.total_excedente) > 0);
|
|
const validados = contas.filter((c) => c.validada).length;
|
|
const pct = contas.length ? Math.round((validados / contas.length) * 100) : 0;
|
|
|
|
const kpi = (rotulo, valor, detalheTxt, classe, alerta) => {
|
|
const tag = alerta ? "button" : "div";
|
|
const ativo = alerta && filtroAlerta === alerta ? " is-active" : "";
|
|
const atributos = alerta ? ` type="button" data-conc-alerta="${alerta}"` : "";
|
|
return `<${tag} class="conc-kpi ${classe}${ativo}"${atributos}>
|
|
<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",
|
|
"",
|
|
""
|
|
)}
|
|
${kpi("Em aberto há mais de 3 meses", pidConcMoeda(soma(vencidas, "total_vencido")), `${vencidas.length} fornecedor(es)`, "conc-kpi--warning", "credito_vencido")}
|
|
${kpi("Pagamentos sem título", pidConcMoeda(soma(excedentes, "total_excedente")), `${excedentes.length} fornecedor(es) · divergência`, "conc-kpi--danger", "debito_excedente")}
|
|
<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 = "";
|
|
renderCards();
|
|
renderContas();
|
|
});
|
|
});
|
|
|
|
filtroStatusBtns.forEach((btn) => {
|
|
const status = btn.dataset.concStatus;
|
|
const quantidade = status === "validada" ? validados : status ? porStatus[status] || 0 : contas.length;
|
|
btn.innerHTML = `${btn.textContent.replace(/\s*\d+$/, "")} <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);
|
|
});
|
|
}
|
|
|
|
function contasFiltradas() {
|
|
const termo = contasBusca.value.trim().toLowerCase();
|
|
let lista = detalhe.contas.filter((c) => {
|
|
// Validadas ficam fora por padrão (pedido do usuário): a lista mostra
|
|
// o que ainda falta revisar. O chip "Validados" as mostra sempre.
|
|
if (c.validada && !mostrarValidadasInput.checked && filtroStatus !== "validada") return false;
|
|
if (filtroAlerta) {
|
|
const temAlerta =
|
|
filtroAlerta === "debito_excedente" ? Number(c.total_excedente) > 0 : c.alertas.some((a) => a.tipo === filtroAlerta);
|
|
if (!temAlerta) return false;
|
|
} else if (filtroStatus === "validada") {
|
|
// validação é uma marcação à parte da situação calculada
|
|
if (!c.validada) return false;
|
|
} else if (filtroStatus && c.status !== filtroStatus) {
|
|
return false;
|
|
}
|
|
return !termo || `${c.conta_numero} ${c.fornecedor}`.toLowerCase().includes(termo);
|
|
});
|
|
if (sortKey) {
|
|
const extrator = sortValor[sortKey];
|
|
lista = lista.slice().sort((a, b) => pidConcCompara(extrator(a), extrator(b)) * sortDir);
|
|
}
|
|
return lista;
|
|
}
|
|
|
|
function renderContas() {
|
|
filtroAlertaEl.hidden = !filtroAlerta;
|
|
if (filtroAlerta) {
|
|
filtroAlertaEl.innerHTML = `Filtrando por: <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) {
|
|
if (!conta.alertas.length) return conta.qtd_pendentes ? `${conta.qtd_pendentes} lançamento(s) pendente(s)` : "";
|
|
// só o primeiro alerta, cortado com reticências: o resto está na expansão
|
|
return conta.alertas[0].mensagem;
|
|
}
|
|
|
|
function montarLinhasConta(conta) {
|
|
const aberta = abertas.has(conta.id);
|
|
const tr = document.createElement("tr");
|
|
tr.className = `conc-row${aberta ? " is-aberta" : ""}`;
|
|
tr.dataset.contaId = conta.id;
|
|
const saldoFinal = Number(conta.saldo_final);
|
|
const vencido = Number(conta.total_vencido);
|
|
tr.innerHTML = `
|
|
<td><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></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();
|
|
substituirConta(resposta.conta);
|
|
}
|
|
|
|
function linhaLancamentoHtml(lanc, comCheckbox, selecionado) {
|
|
const situacao = lanc.situacao;
|
|
const valorAberto = pidConcValorAbertoHtml(situacao);
|
|
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
|
|
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>
|
|
<td class="conc-historico">${historico}${lanc.contrapartida ? `<span class="conc-historico__contrapartida">${pidConcEscape(lanc.contrapartida)}</span>` : ""}</td>
|
|
<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>
|
|
</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>${razao.map((l) => linhaRazaoHtml(l)).join("")}</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);
|
|
|
|
function linhaRazaoHtml(lanc) {
|
|
const historico = lanc.eh_saldo_anterior ? "<em>Saldo anterior</em>" : pidConcEscape(lanc.historico);
|
|
const contrapartida = lanc.contrapartida ? `<span class="conc-historico__contrapartida">${pidConcEscape(lanc.contrapartida)}</span>` : "";
|
|
const comum = `
|
|
<td>${pidConcData(lanc.data)}</td>
|
|
<td>${pidConcEscape(lanc.sequencia || "—")}</td>
|
|
<td class="conc-historico">${historico}${contrapartida}</td>
|
|
<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>
|
|
</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")));
|
|
});
|
|
// "Ocultar conciliados": desmarcado por padrão (pedido do usuário); a
|
|
// escolha fica no estado da conta e sobrevive ao re-render após um vínculo.
|
|
const ocultarCheck = secaoPend.querySelector("[data-ocultar-conciliados]");
|
|
if (ocultarCheck) {
|
|
ocultarCheck.addEventListener("click", (evento) => evento.stopPropagation());
|
|
ocultarCheck.addEventListener("change", () => {
|
|
estado.ocultarConciliados = ocultarCheck.checked;
|
|
const tabelaRazao = secaoPend.querySelector(".conc-subtabela--pendentes");
|
|
if (tabelaRazao) tabelaRazao.classList.toggle("is-ocultando-conciliados", ocultarCheck.checked);
|
|
});
|
|
}
|
|
|
|
secaoPend.querySelectorAll("[data-desfazer-vinculo]").forEach((btn) => {
|
|
btn.addEventListener("click", (evento) => {
|
|
evento.stopPropagation();
|
|
desfazerVinculo(Number(btn.dataset.desfazerVinculo));
|
|
});
|
|
});
|
|
|
|
const resumoSelecaoEl = secaoPend.querySelector("[data-selecao-resumo]");
|
|
const todosCheck = secaoPend.querySelector("[data-lanc-todos]");
|
|
const vincularBtn = secaoPend.querySelector("[data-vincular]");
|
|
const porId = new Map(conta.lancamentos.map((l) => [l.id, l]));
|
|
|
|
function atualizarSelecao() {
|
|
const selecionados = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean);
|
|
const creditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
|
|
const debitos = selecionados.filter((l) => l.natureza === "D").reduce((t, l) => t + Number(l.valor), 0);
|
|
const temAmbos = selecionados.some((l) => l.natureza === "C") && selecionados.some((l) => l.natureza === "D");
|
|
vincularBtn.disabled = !temAmbos;
|
|
painel.querySelectorAll("[data-adicionar]").forEach((btn) => {
|
|
btn.disabled = selecionados.length === 0;
|
|
});
|
|
if (todosCheck) {
|
|
todosCheck.checked = selecionados.length > 0 && selecionados.length === pendentes.length;
|
|
todosCheck.indeterminate = selecionados.length > 0 && selecionados.length < pendentes.length;
|
|
todosCheck.title = selecionados.length ? "Desmarcar todos" : "Marcar todos";
|
|
}
|
|
resumoSelecaoEl.innerHTML = selecionados.length
|
|
? `Créditos <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, diferenca: Math.round((creditos - debitos) * 100) / 100 };
|
|
}
|
|
atualizarSelecao();
|
|
|
|
// Cabeçalho: sem nada marcado, marca todos; com algum marcado (parcial ou
|
|
// todos), desmarca todos.
|
|
if (todosCheck) {
|
|
todosCheck.addEventListener("change", () => {
|
|
const marcar = estado.selecionados.size === 0;
|
|
secaoPend.querySelectorAll("[data-lanc-check]").forEach((check) => {
|
|
check.checked = marcar;
|
|
check.closest("tr").classList.toggle("is-selecionado", marcar);
|
|
const id = Number(check.dataset.lancCheck);
|
|
if (marcar) estado.selecionados.add(id);
|
|
else estado.selecionados.delete(id);
|
|
});
|
|
atualizarSelecao();
|
|
});
|
|
}
|
|
|
|
// A linha inteira marca/desmarca (pedido do usuário); o checkbox fica
|
|
// como indicador. Clique no próprio checkbox segue o comportamento
|
|
// nativo, e arrastar para selecionar texto não conta como clique.
|
|
secaoPend.querySelectorAll(".conc-subtabela--pendentes tbody tr[data-lanc-id]:not([data-vinculo-id])").forEach((tr) => {
|
|
tr.addEventListener("click", (evento) => {
|
|
if (evento.target.closest("[data-lanc-check]")) return;
|
|
if (String(window.getSelection ? window.getSelection() : "").trim()) return;
|
|
const check = tr.querySelector("[data-lanc-check]");
|
|
check.checked = !check.checked;
|
|
check.dispatchEvent(new Event("change"));
|
|
});
|
|
});
|
|
|
|
secaoPend.querySelectorAll("[data-lanc-check]").forEach((check) => {
|
|
check.addEventListener("change", () => {
|
|
const id = Number(check.dataset.lancCheck);
|
|
if (check.checked) estado.selecionados.add(id);
|
|
else estado.selecionados.delete(id);
|
|
check.closest("tr").classList.toggle("is-selecionado", check.checked);
|
|
atualizarSelecao();
|
|
});
|
|
});
|
|
|
|
vincularBtn.addEventListener("click", async () => {
|
|
const { selecionados, diferenca } = atualizarSelecao();
|
|
// Mesmo critério do servidor (vincular/): créditos maiores que os
|
|
// débitos além da tolerância = pagamento parcial, o título continua em
|
|
// aberto pelo saldo.
|
|
const totalCreditos = selecionados.filter((l) => l.natureza === "C").reduce((t, l) => t + Number(l.valor), 0);
|
|
const limite = Math.max(Number(detalhe.tolerancia_valor), (totalCreditos * Number(detalhe.tolerancia_percentual)) / 100);
|
|
if (diferenca > limite) {
|
|
const seguir = await pidConfirm(
|
|
`Os débitos selecionados pagam ${pidConcMoeda(totalCreditos - diferenca)} de ${pidConcMoeda(totalCreditos)}. Vincular como pagamento parcial? O título continua em aberto com ${pidConcMoeda(diferenca)}.`,
|
|
{ titulo: "Pagamento parcial", textoConfirmar: "Vincular" }
|
|
);
|
|
if (!seguir) return;
|
|
} else if (diferenca !== 0) {
|
|
const natureza = diferenca < 0 ? "pago a maior (possível juros/multa)" : "pago a menor (possível desconto)";
|
|
const seguir = await pidConfirm(
|
|
`Os lançamentos selecionados têm diferença de ${pidConcMoeda(Math.abs(diferenca))} (${natureza}). Vincular mesmo assim? O vínculo fica sinalizado nos alertas da conta.`,
|
|
{ titulo: "Vínculo com diferença", textoConfirmar: "Vincular" }
|
|
);
|
|
if (!seguir) return;
|
|
}
|
|
vincularBtn.disabled = true;
|
|
try {
|
|
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-contas/${conta.id}/vincular/`, {
|
|
method: "POST",
|
|
body: { lancamentos: selecionados.map((l) => l.id) },
|
|
});
|
|
aplicarRespostaConta(resposta);
|
|
} catch (erro) {
|
|
vincularBtn.disabled = false;
|
|
pidAlert(erro.message, { titulo: "Não foi possível vincular" });
|
|
}
|
|
});
|
|
|
|
// ---- Vinculados ----
|
|
function montarBlocoVinculo(vinculo) {
|
|
const itens = conta.lancamentos.filter((l) => l.vinculo_id === vinculo.id);
|
|
const manual = vinculo.tipo === "manual" || vinculo.tipo === "parcial";
|
|
const origem = manual ? `manual, por ${pidConcEscape(vinculo.criado_por_nome || "—")}` : "automático";
|
|
const diferenca = Number(vinculo.diferenca);
|
|
let detalheDiferenca = "";
|
|
if (vinculo.tipo === "parcial") {
|
|
const saldo = vinculo.situacao;
|
|
detalheDiferenca = ` · restam <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", async () => {
|
|
const novos = Array.from(estado.selecionados).map((id) => porId.get(id)).filter(Boolean);
|
|
if (!novos.length) return;
|
|
const assinado = (l) => (l.natureza === "C" ? Number(l.valor) : -Number(l.valor));
|
|
const novaDiferenca = [...itens, ...novos].reduce((t, l) => t + assinado(l), 0);
|
|
const arredondada = Math.round(novaDiferenca * 100) / 100;
|
|
const efeito =
|
|
arredondada > 0
|
|
? `restarão ${pidConcMoeda(arredondada)} de diferença (em aberto, se passar da tolerância)`
|
|
: arredondada < 0
|
|
? `ficará pago a maior em ${pidConcMoeda(-arredondada)} (possível juros/multa)`
|
|
: "o vínculo fecha sem diferença";
|
|
const seguir = await pidConfirm(
|
|
`Adicionar ${novos.length} lançamento(s) a este vínculo? Com eles, ${efeito}.`,
|
|
{ titulo: "Adicionar ao vínculo", textoConfirmar: "Adicionar" }
|
|
);
|
|
if (!seguir) return;
|
|
adicionarBtn.disabled = true;
|
|
try {
|
|
const resposta = await pidApiRequest(`/conciliacoes-fornecedores-vinculos/${vinculo.id}/adicionar/`, {
|
|
method: "POST",
|
|
body: { lancamentos: novos.map((l) => l.id) },
|
|
});
|
|
aplicarRespostaConta(resposta);
|
|
} catch (erro) {
|
|
adicionarBtn.disabled = false;
|
|
pidAlert(erro.message, { titulo: "Não foi possível adicionar" });
|
|
}
|
|
});
|
|
}
|
|
|
|
bloco.querySelector("[data-desfazer]").addEventListener("click", () => desfazerVinculo(vinculo.id));
|
|
return bloco;
|
|
}
|
|
|
|
// Pagamento parcial não é conciliação fechada: fica junto dos não
|
|
// conciliados, sempre visível, com o saldo que continua em aberto.
|
|
const parciais = conta.vinculos.filter((v) => v.tipo === "parcial");
|
|
if (parciais.length) {
|
|
const secaoParcial = document.createElement("div");
|
|
secaoParcial.innerHTML = `<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;
|
|
}
|
|
|
|
// ======================================================================
|
|
// Início
|
|
// ======================================================================
|
|
const hash = window.location.hash.match(/^#conciliacao-(\d+)$/);
|
|
if (hash) {
|
|
abrirDetalhe(Number(hash[1]));
|
|
} else {
|
|
carregarHistorico();
|
|
}
|
|
});
|