chore: move BT-list Import button into shared "Import From..." group

The "Import" button on the Bank Transaction list view moved from the
"Kapital Bank" group into the shared "Import From..." dropdown
(created by jey_erp), labelled "Load from Kapital Bank". Switched to a
defensive listview_settings assignment so jey_erp's existing onload
handler is preserved.

Removed client/bank_reconciliation_tool.js and its doctype_js hook —
the BRT extension (checkbox column, Create & Reconcile toolbar) lives
in jey_erp now and works with both Kapital Bank Settings and the new
Bank Integration mappings via a universal resolver. No behaviour change
for kb-mapped Bank Accounts: jey_erp's after_migrate links them to
Kapital Bank Settings via hidden Dynamic Link fields on Bank Account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-08 16:24:03 +00:00
parent 6fbec93fc9
commit cc7dfbeda8
3 changed files with 20 additions and 356 deletions

View File

@ -1,348 +0,0 @@
// Kapital Bank — Bank Reconciliation Tool integration
// Adds checkbox column + "Create & Reconcile" toolbar
frappe.ui.form.on("Bank Reconciliation Tool", {
render: function (frm) {
// Only activate if at least one Kapital Bank Login exists
frappe.db.count("Kapital Bank Login").then(function (count) {
if (!count) return;
if (frm._kb_check_interval) clearInterval(frm._kb_check_interval);
frm._kb_check_interval = setInterval(function () {
const dtm = frm.bank_reconciliation_data_table_manager;
if (dtm && dtm.datatable && !dtm._kb_enhanced) {
clearInterval(frm._kb_check_interval);
KBBRTMapping.enhance(frm, dtm);
}
}, 200);
setTimeout(function () { clearInterval(frm._kb_check_interval); }, 15000);
});
},
});
const KBBRTMapping = {
enhance: function (frm, dtm) {
dtm._kb_enhanced = true;
dtm._kb_frm = frm;
// Rebuild DataTable with checkboxColumn: true
dtm.datatable.destroy();
dtm.datatable = new frappe.DataTable(dtm.$reconciliation_tool_dt.get(0), {
columns: dtm.columns,
data: dtm.transactions,
dynamicRowHeight: true,
checkboxColumn: true,
inlineFilters: true,
});
$(`.${dtm.datatable.style.scopeClass} .dt-scrollable`).css("max-height", "calc(100vh - 400px)");
// Restore ERPNext's Action-button listeners for the new datatable
dtm.set_listeners();
dtm._kb_scopeClass = dtm.datatable.style.scopeClass;
KBBRTMapping.injectToolbar(dtm);
},
injectToolbar: function (dtm) {
$("#kb-brt-mapping-toolbar").remove();
const $toolbar = $(`
<div id="kb-brt-mapping-toolbar"
style="display:none; align-items:center; gap:10px; margin-bottom:8px;
padding:8px 12px; background:var(--fg-color);
border:1px solid var(--border-color); border-radius:6px;">
<span class="kb-brt-selected-count text-muted"></span>
<button class="btn btn-primary btn-sm kb-brt-create-mapping-btn">
${__("Create & Reconcile")}
</button>
</div>
`);
dtm.$reconciliation_tool_dt.before($toolbar);
const scopeClass = dtm.datatable.style.scopeClass;
$(document).off("click.kb_brt_mapping").on(
"click.kb_brt_mapping",
`.${scopeClass} input[type="checkbox"]`,
function () {
setTimeout(() => KBBRTMapping.updateToolbar(dtm, $toolbar), 30);
}
);
$toolbar.find(".kb-brt-create-mapping-btn").on("click", function () {
KBBRTMapping.showMappingDialog(dtm);
});
},
updateToolbar: function (dtm, $toolbar) {
const checked = dtm.datatable.rowmanager.getCheckedRows();
if (checked && checked.length > 0) {
$toolbar.css("display", "flex");
$toolbar.find(".kb-brt-selected-count").text(__("{0} row(s) selected", [checked.length]));
} else {
$toolbar.hide();
}
},
showMappingDialog: function (dtm) {
const checkedIndices = dtm.datatable.rowmanager.getCheckedRows();
if (!checkedIndices || checkedIndices.length === 0) {
frappe.msgprint({ title: __("Warning"), indicator: "orange", message: __("No rows selected.") });
return;
}
// dtm.transactions row: [date, party_type, party(HTML), description, deposit, withdrawal, ..., reference_number, bank_transaction]
const txns = [];
let skippedCount = 0;
checkedIndices.forEach(function (rowIndex) {
const row = dtm.transactions[parseInt(rowIndex, 10)];
if (!row) return;
const description = (row[3] || "").trim();
if (!description) { skippedCount++; return; }
const partyType = (row[1] || "").trim();
const partyName = $("<div>").html(row[2] || "").text().trim();
const deposit = parseFloat(row[4]) || 0;
const withdrawal = parseFloat(row[5]) || 0;
// row[8] = Actions button (original) OR plain name inserted by jey_erp BRT patch
// row[9] = Actions button when jey_erp patch is active (shifted by 1)
const bankTransactionName = $(row[9] || row[8] || "").data("name") || "";
txns.push({
purpose: description,
drcr: withdrawal > 0 ? "D" : "C",
party_type: partyType,
party: partyName,
amount: deposit || withdrawal,
date: (row[0] || "").trim(),
reference_number: (row[7] || "").trim(),
bank_transaction_name: bankTransactionName,
});
});
// Deduplicate for preview only
const purposeMap = new Map();
txns.forEach(function (txn) {
const key = txn.purpose + "|" + txn.drcr;
if (purposeMap.has(key)) {
purposeMap.get(key).count++;
} else {
purposeMap.set(key, {
purpose: txn.purpose,
payment_type: txn.drcr === "D" ? "Pay" : "Receive",
party: txn.party,
count: 1,
});
}
});
if (purposeMap.size === 0) {
frappe.msgprint({ title: __("No Purposes"), indicator: "orange",
message: __("All selected rows have no description text.") });
return;
}
// Preview table
let previewHtml = '<div style="max-height:260px; overflow-y:auto; margin-bottom:12px;">' +
'<table class="table table-bordered" style="width:100%;">' +
"<thead><tr>" +
"<th>" + __("Description") + "</th>" +
'<th style="width:90px;">' + __("Type") + "</th>" +
'<th style="width:140px;">' + __("Party") + "</th>" +
'<th style="width:60px; text-align:center;">' + __("Rows") + "</th>" +
"</tr></thead><tbody>";
purposeMap.forEach(function (e) {
const badge = e.payment_type === "Pay"
? '<span class="label label-danger">Pay</span>'
: '<span class="label label-success">Receive</span>';
previewHtml += "<tr>" +
'<td style="word-break:break-word;font-size:0.9em;">' + frappe.utils.escape_html(e.purpose) + "</td>" +
"<td>" + badge + "</td>" +
"<td>" + (e.party ? frappe.utils.escape_html(e.party) : '<span class="text-muted">—</span>') + "</td>" +
'<td style="text-align:center;">' + e.count + "</td></tr>";
});
previewHtml += "</tbody></table></div>";
if (skippedCount > 0) {
previewHtml += '<div class="alert alert-warning">' +
__("{0} row(s) skipped — no description text.", [skippedCount]) + "</div>";
}
const d = new frappe.ui.Dialog({
title: __("Create & Reconcile") + " (" + purposeMap.size + ")",
size: "large",
fields: [
{
fieldname: "mode",
fieldtype: "Select",
label: __("Action"),
options: "Both\nMappings Only\nDocuments & Reconcile",
default: "Both",
reqd: 1,
onchange: function () { KBBRTMapping._applyModeVisibility(d); },
},
{ fieldname: "preview_html", fieldtype: "HTML", options: previewHtml },
{ fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") },
{
fieldname: "document_type",
fieldtype: "Select",
label: __("Document Type"),
options: "Payment Entry\nJournal Entry",
default: "Payment Entry",
},
{
fieldname: "use_original_purpose",
fieldtype: "Check",
label: __("Use original description"),
onchange: function () {
const on = d.get_value("use_original_purpose");
d.set_df_property("custom_purpose", "hidden", on ? 1 : 0);
d.get_field("custom_purpose").refresh();
},
},
{
fieldname: "custom_purpose",
fieldtype: "Data",
label: __("Custom Purpose Keyword"),
hidden: 0,
description: __("Leave empty to map by party only. All rows will use this keyword instead of their own description."),
},
{ fieldname: "sec_accounts", fieldtype: "Section Break", label: __("GL Accounts") },
{
fieldname: "paid_from", fieldtype: "Link", options: "Account",
label: __("Paid From (Account)"),
},
{ fieldname: "col_break", fieldtype: "Column Break" },
{
fieldname: "paid_to", fieldtype: "Link", options: "Account",
label: __("Paid To (Account)"),
},
],
primary_action_label: __("Create & Reconcile"),
primary_action: function () {
const isDR = d.get_value("mode") === "Documents & Reconcile";
const values = d.get_values(true);
if (!values) return;
if (!isDR) {
const missing = [];
if (!values.paid_from) missing.push(__("Paid From (Account)"));
if (!values.paid_to) missing.push(__("Paid To (Account)"));
if (missing.length) {
frappe.msgprint({ title: __("Missing Values"), indicator: "red",
message: __("Following fields are required:") + "<br>" + missing.join("<br>") });
return;
}
}
let finalTxns = txns.slice();
if (values.mode !== "Documents & Reconcile") {
if (!values.use_original_purpose) {
const cp = (values.custom_purpose || "").trim();
if (cp) {
finalTxns = txns.map(t => Object.assign({}, t, { purpose: cp }));
} else {
finalTxns = txns.map(t => Object.assign({}, t, { purpose: "" }));
if (finalTxns.every(t => !t.party)) {
frappe.msgprint({
title: __("Validation Error"),
indicator: "red",
message: __("Either a purpose keyword or a party is required."),
});
return;
}
}
}
}
KBBRTMapping.submitMappings(d, finalTxns, values.paid_from, values.paid_to, values.document_type, values.mode);
},
});
d._kb_frm = dtm._kb_frm;
d.show();
KBBRTMapping._applyModeVisibility(d);
// Pre-fill custom purpose from the Description inline filter if active
const $fi = $(`.${dtm._kb_scopeClass} .dt-row-filter input.dt-filter[data-name="Description"]`);
const filterVal = ($fi.val() || "").trim();
if (filterVal) {
d.set_value("custom_purpose", filterVal);
}
},
_applyModeVisibility: function (d) {
const isDR = d.get_value("mode") === "Documents & Reconcile";
const toHide = ["preview_html", "sec_options", "document_type", "use_original_purpose",
"custom_purpose", "sec_accounts", "paid_from", "col_break", "paid_to"];
toHide.forEach(fn => d.set_df_property(fn, "hidden", isDR ? 1 : 0));
d.refresh_fields(toHide);
},
submitMappings: function (dialog, txns, paid_from, paid_to, document_type, mode) {
dialog.disable_primary_action();
frappe.call({
method: "kapital_bank.mapping.create_purpose_mappings",
args: {
transactions: JSON.stringify(txns),
paid_from: paid_from,
paid_to: paid_to,
document_type: document_type || "Payment Entry",
mode: mode || "Both",
},
callback: function (r) {
dialog.enable_primary_action();
if (!r.message || !r.message.success) {
frappe.msgprint({ title: __("Error"), indicator: "red",
message: r.message ? r.message.message : __("Unknown error") });
return;
}
dialog.hide();
const res = r.message;
const parts = [];
if (res.created_mappings) parts.push(__("Mappings created: {0}", [res.created_mappings]));
if (res.already_mapped) parts.push(__("Already mapped: {0}", [res.already_mapped]));
if (res.skipped_no_data) parts.push(__("Skipped (no purpose): {0}", [res.skipped_no_data]));
if (res.created_docs) parts.push(__("Documents created: {0}", [res.created_docs]));
if (res.reconciled) parts.push(__("Reconciled: {0}", [res.reconciled]));
if (res.errors && res.errors.length) parts.push(__("Errors: {0}", [res.errors.length]));
frappe.show_alert({
message: parts.join(" &bull; ") || __("No changes"),
indicator: (res.created_mappings > 0 || res.created_docs > 0) ? "green" : "blue",
}, 5);
if (dialog._kb_frm) dialog._kb_frm.refresh();
if (res.errors && res.errors.length) {
KBBRTMapping.showErrors(res.errors);
}
},
error: function () {
dialog.enable_primary_action();
frappe.msgprint({ title: __("Error"), indicator: "red",
message: __("Network error creating mappings") });
},
});
},
showErrors: function (errors) {
let html = '<div style="max-height:500px; overflow-y:auto;">';
errors.forEach(function (err) {
const ref = err.reference_number || __("Unknown");
html += '<div style="margin-bottom:14px; padding:12px; border:1px solid var(--border-color); border-radius:6px; background:var(--bg-color);">';
html += '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; padding-bottom:6px; border-bottom:1px solid var(--border-color);">';
html += '<strong>' + frappe.utils.escape_html(ref) + '</strong>';
html += '<span class="label label-danger">' + __("Error") + '</span>';
html += '</div>';
html += '<div style="color:var(--text-color);">' + frappe.utils.escape_html(err.message || __("Unknown error")) + '</div>';
html += '</div>';
});
html += '</div>';
const d = new frappe.ui.Dialog({
title: __("Errors ({0})", [errors.length]),
size: "large",
fields: [{ fieldname: "errors_html", fieldtype: "HTML", options: html }],
primary_action_label: __("Close"),
primary_action: function () { d.hide(); },
});
d.show();
},
};

View File

@ -722,10 +722,19 @@ KBBTImport.import = {
}; };
// ======= LIST VIEW SETUP ======= // ======= LIST VIEW SETUP =======
frappe.listview_settings['Bank Transaction'] = { // Coexists with jey_erp's bank_transaction_list.js which creates the
// shared "Import From..." button group; we add our item into the same group.
(function () {
const existing = frappe.listview_settings['Bank Transaction'] || {};
const prevOnload = existing.onload;
frappe.listview_settings['Bank Transaction'] = Object.assign({}, existing, {
onload(listview) { onload(listview) {
listview.page.add_inner_button(__('Import'), function() { if (typeof prevOnload === 'function') {
KBBTImport.import.showDialog(); try { prevOnload(listview); } catch (e) { console.error(e); }
}, __('Kapital Bank'));
} }
}; listview.page.add_inner_button(__('Load from Kapital Bank'), function () {
KBBTImport.import.showDialog();
}, __('Import From...'));
}
});
})();

View File

@ -11,10 +11,13 @@ after_migrate = "kapital_bank.setup.after_migrate"
# JS for ERPNext forms # JS for ERPNext forms
# Note: Bank Reconciliation Tool extension (Create & Reconcile, checkbox column,
# mapping-source select) lives in jey_erp/public/js/bank_reconciliation_tool.js
# and works with both Kapital Bank Settings and Bank Integration mappings via
# the universal resolver in jey_erp/bank_integration/mapping_resolver.py.
doctype_js = { doctype_js = {
"Payment Entry": "client/payment_entry.js", "Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js", "Bank Transaction": "client/bank_transaction.js",
"Bank Reconciliation Tool": "client/bank_reconciliation_tool.js",
"Payment Request": "client/payment_request.js", "Payment Request": "client/payment_request.js",
} }