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:
parent
6fbec93fc9
commit
cc7dfbeda8
|
|
@ -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(" • ") || __("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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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...'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue