fix: enhance tree view functionality for accounts and cost centers (#58520)

This commit is contained in:
Sumit Jain
2026-08-31 19:56:24 +05:30
committed by GitHub
parent 7ecfa6b356
commit 31761699ae
11 changed files with 390 additions and 81 deletions

View File

@@ -52,6 +52,47 @@ frappe.treeview_settings["Account"] = {
],
root_label: "Accounts",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the account number renders as a badge (see
// onrender) instead of being glued into the name
return frappe.utils.escape_html(node.data.account_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.account_number) {
flags.push(frappe.ui.badge({ label: node.data.account_number, size: "sm" }));
}
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
const company_currency = company && erpnext.get_currency(company);
if (
node.data.account_currency &&
company_currency &&
node.data.account_currency !== company_currency
) {
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue", size: "sm" }));
}
if (node.data.freeze_account === "Yes") {
flags.push(
$(
`<span class="inline-flex text-ink-gray-4" title="${__("Frozen — entries restricted")}">
${frappe.utils.icon("lock", "sm")}
</span>`
)[0]
);
}
if (flags.length) {
const $flags = $(
'<span class="tree-node-flags inline-flex items-center gap-1.5 ms-2 shrink-0"></span>'
);
flags.forEach((flag) => $flags.append(flag));
$flags.insertAfter(node.$tree_link.find("a.tree-label"));
}
},
on_node_render: function (node, deep) {
const render_balances = () => {
for (let account of cur_tree.account_balance_data) {
@@ -232,7 +273,7 @@ frappe.treeview_settings["Account"] = {
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
if (treeview.can_create) {
treeview.page.set_primary_action(
__("New"),
{ label: __("Add Account"), short_label: __("Add") },
function () {
let root_company = treeview.page.fields_dict.root_company.get_value();
if (root_company) {
@@ -243,13 +284,14 @@ frappe.treeview_settings["Account"] = {
treeview.new_node();
}
},
"add"
"plus"
);
}
},
toolbar: [
{
label: __("Add Child"),
icon: "plus",
condition: function (node) {
return (
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
@@ -272,6 +314,7 @@ frappe.treeview_settings["Account"] = {
return !node.root && frappe.boot.user.can_read.indexOf("GL Entry") !== -1;
},
label: __("View Ledger"),
icon: "book-open",
click: function (node, btn) {
frappe.route_options = {
from_date: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
@@ -286,6 +329,106 @@ frappe.treeview_settings["Account"] = {
},
btnClass: "hidden-xs",
},
{
// same label and mechanism as the Account form's Actions button:
// NOT frappe's generic rename (Allow Rename stays off) — this is
// ERPNext's controlled update that rebuilds the derived
// "number - name - abbr" document name
label: __("Update Account Name / Number"),
icon: "text-cursor-input",
condition: function (node) {
return !node.is_root && frappe.model.can_write("Account");
},
click: function (node) {
const dialog = new frappe.ui.Dialog({
title: __("Update Account Number / Name"),
fields: [
{
fieldtype: "Data",
fieldname: "account_name",
label: __("Account Name"),
reqd: 1,
default: node.data.account_name,
},
{
fieldtype: "Data",
fieldname: "account_number",
label: __("Account Number"),
default: node.data.account_number,
},
],
primary_action_label: __("Update"),
primary_action(values) {
dialog.hide();
frappe.dom.freeze(__("Updating {0}", [node.label]));
frappe.call({
method: "erpnext.accounts.doctype.account.account.update_account_number",
args: {
name: node.label,
account_name: values.account_name,
account_number: values.account_number,
},
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees["Account"];
node.parent_node && treeview.tree.load_children(node.parent_node);
},
always: function () {
frappe.dom.unfreeze();
},
});
},
});
dialog.show();
},
},
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Account");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Account")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -16,6 +16,8 @@ frappe.ui.form.on("Chart of Accounts Importer", {
() => generate_tree_preview(frm),
() => create_import_button(frm),
() => frm.set_df_property("chart_preview", "hidden", 0),
// the preview is the point of this page — open it right away
() => frm.fields_dict.chart_preview.collapse(false),
]);
}
@@ -128,7 +130,6 @@ var create_import_button = function (frm) {
freeze_message: __("Creating Accounts..."),
callback: function (r) {
if (!r.exc) {
clearInterval(frm.page["interval"]);
frm.page.set_indicator(__("Import Successful"), "blue");
create_reset_button(frm);
}
@@ -142,42 +143,95 @@ var create_reset_button = function (frm) {
frm.page
.set_primary_action(__("Reset"), function () {
frm.page.clear_primary_action();
delete frm.page["show_import_button"];
frm.reload_doc();
})
.addClass("btn btn-primary");
};
var validate_coa = function (frm) {
if (frm.doc.import_file) {
let parent = __("All Accounts");
return frappe.call({
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
parent: parent,
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
for_validate: 1,
},
callback: function (r) {
if (r.message["show_import_button"]) {
frm.page["show_import_button"] = Boolean(r.message["show_import_button"]);
}
},
});
}
};
var generate_tree_preview = function (frm) {
let parent = __("All Accounts");
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
const wrapper = $(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
// search + expand/collapse-all lean on frappe.ui.Tree helpers added with
// row mode; when running against an older frappe that predates them, skip
// this toolbar so the preview still renders (just without the extras)
const has_row_helpers =
typeof frappe.ui.Tree.prototype.get_expansion_state === "function" &&
typeof frappe.ui.Tree.prototype.filter_nodes === "function";
let tree;
let deep_loaded = false;
let search_text = "";
let update_buttons = () => {};
if (has_row_helpers) {
// same toolbar anatomy as the tree view: search on the left,
// expand/collapse-all on the right (three-state: fully collapsed ->
// Expand All, fully expanded -> Collapse All, partially expanded -> both)
const $toolbar = $('<div class="flex items-center gap-2 mb-2"></div>').appendTo(wrapper);
const search_control = frappe.ui.form.make_control({
df: { fieldtype: "Data", fieldname: "preview_search", placeholder: __("Search") },
parent: $toolbar,
only_input: true,
});
search_control.refresh();
$(search_control.wrapper).addClass("m-0").css("width", "220px");
search_control.$input.addClass("input-xs");
search_control.$input.on(
"input",
frappe.utils.debounce(() => {
search_text = search_control.$input.val();
const run = () => {
// a newer keystroke superseded this one while the deep load ran
if (search_text !== search_control.$input.val()) return;
tree.filter_nodes(search_text);
};
if (!search_text || deep_loaded) {
run();
return;
}
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
run();
});
}, 300)
);
const $actions = $('<div class="ms-auto flex items-center gap-1"></div>').appendTo($toolbar);
update_buttons = () => {
const state = tree.get_expansion_state();
$expand_all.prop("disabled", !(state === "collapsed" || state === "partial"));
$collapse_all.prop("disabled", !(state === "expanded" || state === "partial"));
};
// tooltip on a wrapper: a disabled es-button has pointer-events:none,
// so hover falls through to the wrapper and the tooltip still shows
const make_action = (icon, label, onclick) => {
const $btn = $(
frappe.ui.button({ icon, disabled: true, onclick, attrs: { "aria-label": label } })
);
const $wrapper = $('<span class="inline-flex"></span>').append($btn).appendTo($actions);
frappe.ui.tooltip($wrapper, { text: label });
return $btn;
};
var $expand_all = make_action("chevrons-up-down", __("Expand All"), () => {
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
});
});
var $collapse_all = make_action("chevrons-down-up", __("Collapse All"), () => {
tree.load_children(tree.root_node, false);
});
}
// generate tree structure based on the csv data
return new frappe.ui.Tree({
parent: $(frm.fields_dict["chart_tree"].wrapper),
tree = new frappe.ui.Tree({
parent: wrapper,
label: parent,
expandable: true,
// read-only preview: row-mode visuals without actions or hover cards
// (ignored by an older frappe, which renders the legacy tree)
row_style: true,
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
@@ -185,8 +239,9 @@ var generate_tree_preview = function (frm) {
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
},
onclick: function (node) {
parent = node.value;
},
on_node_render: () => update_buttons(),
// expanded flips right after this callback — check on the next tick
on_click: () => setTimeout(update_buttons, 0),
});
return tree;
};

View File

@@ -42,6 +42,37 @@ frappe.treeview_settings["Cost Center"] = {
},
],
ignore_fields: ["parent_cost_center"],
toolbar: [
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Cost Center");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Cost Center")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
onload: function (treeview) {
function get_company() {
return treeview.page.fields_dict.company.get_value();
@@ -82,3 +113,22 @@ frappe.treeview_settings["Cost Center"] = {
);
},
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -236,6 +236,8 @@ async function refresh_tree_view(dialog, account_rows) {
parent: wrapper,
label: company,
root_value: company,
// read-only preview: row-mode visuals without actions
row_style: true,
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
toolbar: [],

View File

@@ -1367,12 +1367,13 @@ def get_children(
else:
filters.append([parent_fieldname, "=", parent])
account_fields = ["account_name", "account_number", "account_currency", "freeze_account"]
if is_root:
fields += ["root_type", "report_type", "account_currency"] if doctype == "Account" else []
fields += ["root_type", "report_type", *account_fields] if doctype == "Account" else []
filters.append(["company", "=", company])
else:
fields += ["root_type", "account_currency"] if doctype == "Account" else []
fields += ["root_type", *account_fields] if doctype == "Account" else []
fields += [parent_fieldname + " as parent"]
acc = frappe.get_list(doctype, fields=fields, filters=filters)

View File

@@ -46,9 +46,10 @@ frappe.treeview_settings["BOM"] = {
me.make_tree();
},
toolbar: [
{ toggle_btn: true },
{
label: __("Edit"),
icon: "pencil",
inline: true,
condition: function (node) {
return node.expandable;
},

View File

@@ -40,6 +40,7 @@ frappe.treeview_settings["Task"] = {
toolbar: [
{
label: __("Add Multiple"),
icon: "list-plus",
condition: function (node) {
return node.expandable;
},
@@ -75,7 +76,6 @@ frappe.treeview_settings["Task"] = {
data: dialog.get_values()["multiple_tasks"],
parent: node.data.value,
},
callback: function () {},
});
},
primary_action_label: __("Create"),

View File

@@ -42,6 +42,7 @@ class BOMConfigurator {
doctype: "BOM Configurator",
page: this.page,
expandable: true,
use_row_actions: true,
title: __("Configure Product Assembly"),
breadcrumb: "Manufacturing",
get_tree_nodes: "erpnext.manufacturing.doctype.bom_creator.bom_creator.get_children",
@@ -98,13 +99,11 @@ class BOMConfigurator {
amount = frappe.format(amount, { fieldtype: "Currency", currency: frm_obj.frm.doc.currency });
$(`
<div class="pill small pull-right bom-qty-pill"
<div class="pill small bom-qty-pill"
style="background-color: var(--bg-white);
color: var(--text-on-gray);
font-weight:450;
margin-right: 40px;
display: inline-flex;
min-width: 128px;
border: 1px solid var(--bg-gray);
">
<div style="padding-right:5px" data-bom-qty-docname="${docname}">${qty} ${uom}</div>
@@ -119,15 +118,17 @@ class BOMConfigurator {
this.frm?.doc.docstatus === 0
? [
{
label: __(frappe.utils.icon("pencil", "sm") + " BOM"),
label: __("Edit BOM"),
icon: "pencil",
inline: true,
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.edit_bom(node, view);
},
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("plus", "sm") + " Raw Material"),
label: __("Add Raw Material"),
icon: "plus",
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.add_item(node, view);
@@ -135,10 +136,10 @@ class BOMConfigurator {
condition: function (node) {
return node.expandable;
},
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("plus", "sm") + " Sub Assembly"),
label: __("Add Sub Assembly"),
icon: "folder-tree",
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.add_sub_assembly(node, view);
@@ -146,10 +147,10 @@ class BOMConfigurator {
condition: function (node) {
return node.expandable;
},
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("plus", "sm") + " Phantom Item"),
label: __("Add Phantom Item"),
icon: "box",
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.add_sub_assembly(node, view, true);
@@ -157,29 +158,27 @@ class BOMConfigurator {
condition: function (node) {
return node.expandable;
},
btnClass: "hidden-xs",
},
{
label: __("Collapse All"),
label: __("Expand / Collapse All"),
icon: "chevrons-down-up",
condition: function (node) {
return node.is_root && node.expandable;
},
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
if (!node.expanded) {
view.tree.load_children(node, true);
$(node.parent[0]).find(".tree-children").show();
node.$toolbar.find(".expand-all-btn").html(__("Collapse All"));
} else {
node.$tree_link.trigger("click");
node.$toolbar.find(".expand-all-btn").html(__("Expand All"));
}
let tree = view.tree;
// state helper is new-frappe; fall back to the
// root's own flag on older frappe
let expanded = tree.get_expansion_state
? tree.get_expansion_state() === "expanded"
: node.expanded;
tree.load_children(tree.root_node, !expanded);
},
condition: function (node) {
return node.expandable && node.is_root;
},
btnClass: "hidden-xs expand-all-btn",
},
{
label: __(frappe.utils.icon("move", "sm") + " Sub Assembly"),
label: __("Convert to Sub Assembly"),
icon: "folder-tree",
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.convert_to_sub_assembly(node, view);
@@ -187,10 +186,10 @@ class BOMConfigurator {
condition: function (node) {
return !node.expandable;
},
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("move", "sm") + " Phantom Item"),
label: __("Convert to Phantom Item"),
icon: "box",
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.convert_to_sub_assembly(node, view, true);
@@ -198,10 +197,11 @@ class BOMConfigurator {
condition: function (node) {
return !node.expandable;
},
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("delete", "sm") + " Item"),
label: __("Delete Item"),
icon: "trash-2",
danger: true,
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.delete_node(node, view);
@@ -209,28 +209,25 @@ class BOMConfigurator {
condition: function (node) {
return !node.is_root;
},
btnClass: "hidden-xs",
},
]
: [
{
label: __("Expand All"),
label: __("Expand / Collapse All"),
icon: "chevrons-down-up",
condition: function (node) {
return node.is_root && node.expandable;
},
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
if (!node.expanded) {
view.tree.load_children(node, true);
$(node.parent[0]).find(".tree-children").show();
node.$toolbar.find(".expand-all-btn").html(__("Collapse All"));
} else {
node.$tree_link.trigger("click");
node.$toolbar.find(".expand-all-btn").html(__("Expand All"));
}
let tree = view.tree;
// state helper is new-frappe; fall back to the
// root's own flag on older frappe
let expanded = tree.get_expansion_state
? tree.get_expansion_state() === "expanded"
: node.expanded;
tree.load_children(tree.root_node, !expanded);
},
condition: function (node) {
return node.expandable && node.is_root;
},
btnClass: "hidden-xs expand-all-btn",
},
],
};

View File

@@ -312,6 +312,8 @@ erpnext.setup.slides_settings = [
parent: $(dialog.body),
label: parent,
expandable: true,
// read-only preview: row-mode visuals without actions
row_style: true,
method: "erpnext.accounts.utils.get_coa",
args: {
chart: chart_template,

View File

@@ -626,3 +626,60 @@ body[data-route="pos"] {
transform: translateY(-50%);
z-index: 1;
}
// BOM Creator's tree. The qty/amount pill's positioning lives here (not in
// inline styles) so it can differ by tree mode:
//
// * Legacy — old frappe (or any tree without new frappe's `.tree-rows`
// class): restore the original float layout, so BOM looks unchanged when
// running against a frappe that predates row mode.
// * Row mode — new frappe adds `.tree-rows`: lay the pill out as the row's
// value column (like .balance-area) and let labels stay readable.
//
// Both directions stay compatible: nothing here changes base markup, and the
// legacy rule only applies where `.tree-rows` is absent.
.tree:not(.tree-rows) .bom-qty-pill {
float: right;
margin-right: 40px;
min-width: 128px;
}
.tree.tree-rows {
position: relative;
// child-row pill = value column, pinned to the row's right edge
li.tree-node > .bom-qty-pill {
float: none;
flex: none;
align-self: center;
margin: 0 8px 0 auto;
white-space: nowrap;
}
// the root row is a direct child of the wrapper (not a flex li), so its
// pill can't margin-auto right — pin it to the wrapper's top-right, where
// the always-first root row sits. width:max-content stops it stretching.
> .bom-qty-pill {
position: absolute;
// inset shorthand sets left:auto explicitly (something else sets left,
// which would otherwise beat a lone `right`)
inset: 5px 8px auto auto;
float: none;
margin: 0;
min-width: 0;
width: max-content;
}
// keep the label readable beside the pill
.tree-link .tree-label {
min-width: 6ch;
}
// the click-toolbar (Edit / + Raw Material / …) wraps to its own line
// under the row instead of fighting the value column
.tree-node-toolbar {
flex: 1 1 100%;
order: 3;
margin: 2px 0 4px 24px;
}
}

View File

@@ -13,9 +13,10 @@ frappe.treeview_settings["Employee"] = {
disable_add_node: true,
get_tree_root: false,
toolbar: [
{ toggle_btn: true },
{
label: __("Edit"),
icon: "pencil",
inline: true,
condition: function (node) {
return !node.is_root;
},