From c1ba35b25b056053c886b732cd59ab6c08ba1484 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 20 Jun 2021 12:42:06 +0530 Subject: [PATCH] feat: Organizational Chart --- erpnext/hr/doctype/employee/employee.py | 5 +- .../hr/page/organizational_chart/__init__.py | 0 .../page/organizational_chart/node_card.html | 27 ++ .../organizational_chart.js | 409 ++++++++++++++++++ .../organizational_chart.json | 26 ++ .../organizational_chart.py | 49 +++ erpnext/public/build.json | 3 +- erpnext/public/scss/organizational_chart.scss | 209 +++++++++ 8 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 erpnext/hr/page/organizational_chart/__init__.py create mode 100644 erpnext/hr/page/organizational_chart/node_card.html create mode 100644 erpnext/hr/page/organizational_chart/organizational_chart.js create mode 100644 erpnext/hr/page/organizational_chart/organizational_chart.json create mode 100644 erpnext/hr/page/organizational_chart/organizational_chart.py create mode 100644 erpnext/public/scss/organizational_chart.scss diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index ed7d588434..7917e3abf5 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -476,13 +476,14 @@ def get_employee_emails(employee_list): return employee_emails @frappe.whitelist() -def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): +def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False, fields=None): filters = [['status', '!=', 'Left']] if company and company != 'All Companies': filters.append(['company', '=', company]) - fields = ['name as value', 'employee_name as title'] + if not fields: + fields = ['name as value', 'employee_name as title'] if is_root: parent = '' diff --git a/erpnext/hr/page/organizational_chart/__init__.py b/erpnext/hr/page/organizational_chart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/hr/page/organizational_chart/node_card.html b/erpnext/hr/page/organizational_chart/node_card.html new file mode 100644 index 0000000000..057c45ee86 --- /dev/null +++ b/erpnext/hr/page/organizational_chart/node_card.html @@ -0,0 +1,27 @@ +
+
+
+ + + +
+
+
+ {{ name }} +
+ {{ frappe.utils.icon("edit", "xs") }} + {{ __("Edit") }} +
+
+
+
{{ title }}
+ + {% if connections == 1 %} +
· {{ connections }} {{ __("Connection") }}
+ {% else %} +
· {{ connections }} {{ __("Connections") }}
+ {% endif %} +
+
+
+
\ No newline at end of file diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js new file mode 100644 index 0000000000..04bd9422bd --- /dev/null +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -0,0 +1,409 @@ +frappe.pages['organizational-chart'].on_page_load = function(wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: __('Organizational Chart'), + single_column: true + }); + + let organizational_chart = new OrganizationalChart(wrapper); + $(wrapper).bind('show', ()=> { + organizational_chart.show(); + }); +}; + +class OrganizationalChart { + + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = wrapper.page; + + this.page.main.css({ + 'min-height': '300px', + 'max-height': '600px', + 'overflow': 'auto', + 'position': 'relative' + }); + this.page.main.addClass('frappe-card'); + + this.nodes = {}; + this.setup_node_class(); + } + + setup_node_class() { + let me = this; + this.Node = class { + constructor({ + id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line + }) { + // to setup values passed via constructor + $.extend(this, arguments[0]); + + this.expanded = 0; + + me.nodes[this.id] = this; + me.make_node_element(this); + me.setup_node_click_action(this); + } + } + } + + make_node_element(node) { + let node_card = frappe.render_template('node_card', { + id: node.id, + name: node.name, + title: node.title, + image: node.image, + parent: node.parent_id, + connections: node.connections + }); + + node.parent.append(node_card); + node.$link = $(`#${node.id}`); + } + + show() { + frappe.breadcrumbs.add('HR'); + + let me = this; + let company = this.page.add_field({ + fieldtype: 'Link', + options: 'Company', + fieldname: 'company', + placeholder: __('Select Company'), + default: frappe.defaults.get_default('company'), + only_select: true, + reqd: 1, + change: () => { + me.company = undefined; + + if (company.get_value() && me.company != company.get_value()) { + me.company = company.get_value(); + + // svg for connectors + me.make_svg_markers() + + if (me.$hierarchy) + me.$hierarchy.remove(); + + // setup hierarchy + me.$hierarchy = $( + ``); + + me.page.main.append(me.$hierarchy); + me.render_root_node(); + } + } + }); + + company.refresh(); + $(`[data-fieldname="company"]`).trigger('change'); + } + + make_svg_markers() { + $('#arrows').remove(); + + this.page.main.prepend(` + + + + + + + + + + + + + + + + + + + `); + } + + render_root_node() { + this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; + + let me = this; + + frappe.call({ + method: me.method, + args: { + company: me.company + }, + callback: function(r) { + if (r.message.length) { + let data = r.message[0]; + + let root_node = new me.Node({ + id: data.name, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: true, + connections: data.connections, + is_root: true, + }); + + me.expand_node(root_node); + } + } + }) + } + + expand_node(node) { + let is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id; + this.set_selected_node(node); + this.show_active_path(node); + this.collapse_previous_level_nodes(node); + + // since the previous node collapses, all connections to that node need to be rebuilt + // if a sibling node is clicked, connections don't need to be rebuilt + if (!is_sibling) { + // rebuild outgoing connections + this.refresh_connectors(node.parent_id); + + // rebuild incoming connections + let grandparent = $(`#${node.parent_id}`).attr('data-parent'); + this.refresh_connectors(grandparent) + } + + if (node.expandable && !node.expanded) { + return this.load_children(node); + } + } + + collapse_node() { + if (this.selected_node.expandable) { + this.selected_node.$children.hide(); + $(`path[data-parent="${this.selected_node.id}"]`).hide(); + this.selected_node.expanded = false; + } + } + + show_active_path(node) { + // mark node parent on active path + $(`#${node.parent_id}`).addClass('active-path'); + } + + load_children(node) { + frappe.run_serially([ + () => this.get_child_nodes(node.id), + (child_nodes) => this.render_child_nodes(node, child_nodes) + ]); + } + + get_child_nodes(node_id) { + let me = this; + return new Promise(resolve => { + frappe.call({ + method: this.method, + args: { + parent: node_id, + company: me.company + }, + callback: (r) => { + resolve(r.message); + } + }); + }); + } + + render_child_nodes(node, child_nodes) { + const last_level = this.$hierarchy.find('.level:last').index(); + const current_level = $(`#${node.id}`).parent().parent().parent().index(); + + if (last_level === current_level) { + this.$hierarchy.append(` +
  • + `); + } + + if (!node.$children) { + node.$children = $('') + .hide() + .appendTo(this.$hierarchy.find('.level:last')); + + node.$children.empty(); + + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_node(node, data); + + setTimeout(() => { + this.add_connector(node.id, data.name); + }, 250); + }); + } + } + + node.$children.show(); + $(`path[data-parent="${node.id}"]`).show(); + node.expanded = true; + } + + add_node(node, data) { + var $li = $('
  • '); + + return new this.Node({ + id: data.name, + parent: $li.appendTo(node.$children), + parent_id: node.id, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: data.expandable, + connections: data.connections, + children: undefined + }); + } + + add_connector(parent_id, child_id) { + let parent_node = document.querySelector(`#${parent_id}`); + let child_node = document.querySelector(`#${child_id}`); + + // variable for the namespace + const svgns = 'http://www.w3.org/2000/svg'; + let path = document.createElementNS(svgns, 'path'); + + // we need to connect right side of the parent to the left side of the child node + let pos_parent_right = { + x: parent_node.offsetLeft + parent_node.offsetWidth, + y: parent_node.offsetTop + parent_node.offsetHeight / 2 + }; + let pos_child_left = { + x: child_node.offsetLeft - 5, + y: child_node.offsetTop + child_node.offsetHeight / 2 + }; + + let connector = + "M" + + (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + + "C" + + (pos_parent_right.x + 100) + "," + (pos_parent_right.y) + " " + + (pos_child_left.x - 100) + "," + (pos_child_left.y) + " " + + (pos_child_left.x) + "," + (pos_child_left.y); + + path.setAttribute("d", connector); + path.setAttribute("data-parent", parent_id); + path.setAttribute("data-child", child_id); + + if ($(`#${parent_id}`).hasClass('active')) { + path.setAttribute("class", "active-connector"); + path.setAttribute("marker-start", "url(#arrowstart-active)"); + path.setAttribute("marker-end", "url(#arrowhead-active)"); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + path.setAttribute("class", "collapsed-connector"); + path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); + path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); + } + + $('#connectors').append(path); + } + + set_selected_node(node) { + // remove .active class from the current node + $('.active').removeClass('active'); + + // add active class to the newly selected node + this.selected_node = node; + node.$link.addClass('active'); + } + + collapse_previous_level_nodes(node) { + let node_parent = $(`#${node.parent_id}`); + + let previous_level_nodes = node_parent.parent().parent().children('li'); + if (node_parent.parent().hasClass('root-level')) { + previous_level_nodes = node_parent.parent().children('li'); + } + + let node_card = undefined; + + previous_level_nodes.each(function() { + node_card = $(this).find('.node-card'); + + if (!node_card.hasClass('active-path')) { + node_card.addClass('collapsed'); + } + }); + } + + refresh_connectors(node_parent) { + if (!node_parent) return; + + $(`path[data-parent="${node_parent}"]`).remove(); + + frappe.run_serially([ + () => this.get_child_nodes(node_parent), + (child_nodes) => { + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_connector(node_parent, data.name); + }); + } + } + ]); + } + + setup_node_click_action(node) { + let me = this; + let node_element = $(`#${node.id}`); + + node_element.click(function() { + let is_sibling = me.selected_node.parent_id === node.parent_id; + + if (is_sibling) { + me.collapse_node(); + } else if (node_element.is(':visible') + && (node_element.hasClass('collapsed') || node_element.hasClass('active-path'))) { + me.remove_levels_after_node(node); + me.remove_orphaned_connectors(); + } + + me.expand_node(node); + }); + } + + remove_levels_after_node(node) { + let level = $(`#${node.id}`).parent().parent().parent(); + + if ($(`#${node.id}`).parent().hasClass('root-level')) { + level = $(`#${node.id}`).parent(); + } + + level = $('.hierarchy > li:eq('+ level.index() + ')'); + level.nextAll('li').remove(); + + let nodes = level.find('.node-card'); + let node_object = undefined; + + $.each(nodes, (_i, element) => { + node_object = this.nodes[element.id]; + node_object.expanded = 0; + node_object.$children = undefined; + }); + + nodes.removeClass('collapsed active-path'); + } + + remove_orphaned_connectors() { + let paths = $('#connectors > path'); + $.each(paths, (_i, path) => { + let parent = $(path).data('parent'); + let child = $(path).data('child'); + + if ($(parent).length || $(child).length) + return; + + $(path).remove(); + }) + } +} diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.json b/erpnext/hr/page/organizational_chart/organizational_chart.json new file mode 100644 index 0000000000..d802781320 --- /dev/null +++ b/erpnext/hr/page/organizational_chart/organizational_chart.json @@ -0,0 +1,26 @@ +{ + "content": null, + "creation": "2021-05-25 10:53:10.107241", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2021-05-25 10:53:18.201931", + "modified_by": "Administrator", + "module": "HR", + "name": "organizational-chart", + "owner": "Administrator", + "page_name": "Organizational Chart", + "roles": [ + { + "role": "HR User" + }, + { + "role": "HR Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Organizational Chart" +} \ No newline at end of file diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py new file mode 100644 index 0000000000..be2964530b --- /dev/null +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals +import frappe + +@frappe.whitelist() +def get_children(parent=None, company=None, is_root=False, is_tree=False, fields=None): + + filters = [['status', '!=', 'Left']] + if company and company != 'All Companies': + filters.append(['company', '=', company]) + + if not fields: + fields = ['employee_name', 'name', 'reports_to', 'image', 'designation'] + + if is_root: + parent = '' + if parent and company and parent!=company: + filters.append(['reports_to', '=', parent]) + else: + filters.append(['reports_to', '=', '']) + + employees = frappe.get_list('Employee', fields=fields, + filters=filters, order_by='name') + + for employee in employees: + is_expandable = frappe.get_all('Employee', filters=[ + ['reports_to', '=', employee.get('name')] + ]) + employee.connections = get_connections(employee.name) + employee.expandable = 1 if is_expandable else 0 + + return employees + + +def get_connections(employee): + num_connections = 0 + + connections = frappe.get_list('Employee', filters=[ + ['reports_to', '=', employee] + ]) + num_connections += len(connections) + + while connections: + for entry in connections: + connections = frappe.get_list('Employee', filters=[ + ['reports_to', '=', entry.name] + ]) + num_connections += len(connections) + + return num_connections \ No newline at end of file diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 7a3cb838a9..d3ebcdf7e7 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -3,7 +3,8 @@ "public/less/erpnext.less", "public/less/hub.less", "public/scss/call_popup.scss", - "public/scss/point-of-sale.scss" + "public/scss/point-of-sale.scss", + "public/scss/organizational_chart.scss" ], "css/marketplace.css": [ "public/less/hub.less" diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss new file mode 100644 index 0000000000..62f6ddcb6e --- /dev/null +++ b/erpnext/public/scss/organizational_chart.scss @@ -0,0 +1,209 @@ +.node-card { + background: white; + stroke: 1px solid var(--gray-200); + box-shadow: var(--shadow-base); + border-radius: 0.5rem; + padding: 0.75rem; + margin-left: 3rem; + width: 18rem; + + .btn-edit-node { + display: none; + } + + .edit-chart-node { + display: none; + } + + .node-edit-icon { + display: none; + } +} + +.node-image { + width: 3.0rem; + height: 3.0rem; +} + +.node-name { + font-size: 1rem; + line-height: 1.72; +} + +.node-title { + font-size: 0.75rem; + line-height: 1.35; +} + +.node-connections { + font-size: 0.75rem; + line-height: 1.35; +} + +.node-card.active { + background: var(--blue-50); + border: 1px solid var(--blue-500); + box-shadow: var(--shadow-md); + border-radius: 0.5rem; + padding: 0.75rem; + width: 18rem; + height: 5rem; + + .btn-edit-node { + display: flex; + background: var(--blue-100); + color: var(--blue-500); + padding: .25rem .5rem; + font-size: .75rem; + justify-content: center; + box-shadow: var(--shadow-sm); + } + + .edit-chart-node { + display: block; + } + + .node-edit-icon { + display: block; + } + + .edit-chart-node { + margin-right: 0.25rem; + } + + .node-edit-icon > .icon{ + stroke: var(--blue-500); + } + + .node-name { + align-items: center; + justify-content: space-between; + margin-bottom: 2px; + } +} + +.node-card.active-path { + background: var(--blue-100); + border: 1px solid var(--blue-300); + box-shadow: var(--shadow-sm); + border-radius: 0.5rem; + padding: 0.75rem; + width: 15rem; + height: 3.0rem; + + .btn-edit-node { + display: none !important; + } + + .edit-chart-node { + display: none; + } + + .node-edit-icon { + display: none; + } + + .node-info { + display: none; + } + + .node-title { + display: none; + } + + .node-connections { + display: none; + } + + .node-name { + font-size: 0.85rem; + line-height: 1.35; + } + + .node-image { + width: 1.5rem; + height: 1.5rem; + } + + .node-meta { + align-items: baseline; + } +} + +.node-card.collapsed { + background: white; + stroke: 1px solid var(--gray-200); + box-shadow: var(--shadow-sm); + border-radius: 0.5rem; + padding: 0.75rem; + width: 15rem; + height: 3.0rem; + + .btn-edit-node { + display: none !important; + } + + .edit-chart-node { + display: none; + } + + .node-edit-icon { + display: none; + } + + .node-info { + display: none; + } + + .node-title { + display: none; + } + + .node-connections { + display: none; + } + + .node-name { + font-size: 0.85rem; + line-height: 1.35; + } + + .node-image { + width: 1.5rem; + height: 1.5rem; + } + + .node-meta { + align-items: baseline; + } +} + +// horizontal hierarchy tree view +.hierarchy { + display: flex; + padding-top: 30px; +} + +.hierarchy li { + list-style-type: none; +} + +.child-node { + margin: 0px 0px 16px 0px; +} + +.level { + margin-right: 8px; +} + +#arrows { + position: absolute; +} + +.active-connector { + stroke: var(--blue-500); +} + +.collapsed-connector { + stroke: var(--blue-300); +}