feat: org chart mobile interactions
diff --git a/erpnext/hr/page/organizational_chart/node_card.html b/erpnext/hr/page/organizational_chart/node_card.html
index 057c45e..e42e54f 100644
--- a/erpnext/hr/page/organizational_chart/node_card.html
+++ b/erpnext/hr/page/organizational_chart/node_card.html
@@ -17,9 +17,9 @@
<div class="node-title text-muted ellipsis">{{ title }}</div>
{% if connections == 1 %}
- <div class="node-connections text-muted ml-2">· {{ connections }} {{ __("Connection") }} </div>
+ <div class="node-connections text-muted ml-2 ellipsis">· {{ connections }}</div>
{% else %}
- <div class="node-connections text-muted ml-2">· {{ connections }} {{ __("Connections") }} </div>
+ <div class="node-connections text-muted ml-2 ellipsis">· {{ connections }}</div>
{% endif %}
</div>
</div>
diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js
index 04bd942..5739a11 100644
--- a/erpnext/hr/page/organizational_chart/organizational_chart.js
+++ b/erpnext/hr/page/organizational_chart/organizational_chart.js
@@ -5,13 +5,20 @@
single_column: true
});
- let organizational_chart = new OrganizationalChart(wrapper);
+ // let organizational_chart = undefined;
+ // if (frappe.is_mobile()) {
+ // organizational_chart = new OrgChartMobile(wrapper);
+ // } else {
+ // organizational_chart = new OrgChart(wrapper);
+ // }
+
+ let organizational_chart = new OrgChartMobile(wrapper);
$(wrapper).bind('show', ()=> {
organizational_chart.show();
});
};
-class OrganizationalChart {
+class OrgChart {
constructor(wrapper) {
this.wrapper = $(wrapper);
@@ -407,3 +414,321 @@
})
}
}
+
+
+class OrgChartMobile {
+
+ 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,
+ is_mobile: 1
+ });
+
+ node.parent.append(node_card);
+ node.$link = $(`#${node.id}`);
+ node.$link.addClass('mobile-node');
+ }
+
+ 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();
+
+ if (me.$hierarchy)
+ me.$hierarchy.remove();
+
+ // setup hierarchy
+ me.$hierarchy = $(
+ `<ul class="hierarchy-mobile">
+ <li class="root-level level"></li>
+ </ul>`);
+
+ me.page.main.append(me.$hierarchy);
+ me.render_root_node();
+ }
+ }
+ });
+
+ company.refresh();
+ $(`[data-fieldname="company"]`).trigger('change');
+ }
+
+ 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) {
+ this.set_selected_node(node);
+ this.show_active_path(node);
+
+ if (node.expandable && !node.expanded) {
+ return this.load_children(node);
+ }
+ }
+
+ collapse_node() {
+ let node = this.selected_node;
+ if (node.expandable) {
+ node.$children.hide();
+ node.expanded = false;
+
+ // add a collapsed level to show the collapsed parent
+ // and a button beside it to move to that level
+ let node_parent = node.$link.parent();
+ node_parent.prepend(
+ `<div class="collapsed-level d-flex flex-row"></div>`
+ );
+
+ node_parent
+ .find('.collapsed-level')
+ .append(node.$link);
+
+ frappe.run_serially([
+ () => this.get_child_nodes(node.parent_id, node.id),
+ (child_nodes) => this.get_node_group(child_nodes, node.id),
+ (node_group) => {
+ node_parent.find('.collapsed-level')
+ .append(node_group);
+ }
+ ]);
+ }
+ }
+
+ 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, exclude_node=null) {
+ let me = this;
+ return new Promise(resolve => {
+ frappe.call({
+ method: this.method,
+ args: {
+ parent: node_id,
+ company: me.company,
+ exclude_node: exclude_node
+ },
+ callback: (r) => {
+ resolve(r.message);
+ }
+ });
+ });
+ }
+
+ render_child_nodes(node, child_nodes) {
+ if (!node.$children) {
+ node.$children = $('<ul class="node-children"></ul>')
+ .hide()
+ .appendTo(node.$link.parent());
+
+ node.$children.empty();
+
+ if (child_nodes) {
+ $.each(child_nodes, (_i, data) => {
+ this.add_node(node, data);
+ $(`#${data.name}`).addClass('active-child');
+ });
+ }
+ }
+
+ node.$children.show();
+ node.expanded = true;
+ }
+
+ add_node(node, data) {
+ var $li = $('<li class="child-node"></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
+ });
+ }
+
+ 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');
+ }
+
+ setup_node_click_action(node) {
+ let me = this;
+ let node_element = $(`#${node.id}`);
+ let node_object = null;
+
+ node_element.click(function() {
+ if (node_element.is(':visible') && node_element.hasClass('active-path')) {
+ me.remove_levels_after_node(node);
+ } else {
+ me.add_node_to_hierarchy(node, true);
+ me.collapse_node();
+ }
+
+ me.expand_node(node);
+ });
+ }
+
+ add_node_to_hierarchy(node) {
+ this.$hierarchy.append(`
+ <li class="level">
+ <div class="node-level d-flex flex-row">
+ </div>
+ </li>
+ `);
+
+ node.$link.appendTo(this.$hierarchy.find('.level:last'));
+ }
+
+ get_node_group(nodes, sibling) {
+ let limit = 2;
+ const display_nodes = nodes.slice(0, limit);
+ const extra_nodes = nodes.slice(limit);
+
+ let html = display_nodes.map(node =>
+ this.get_avatar(node)
+ ).join('');
+
+ if (extra_nodes.length === 1) {
+ let node = extra_nodes[0];
+ html += this.get_avatar(node);
+ } else if (extra_nodes.length > 1) {
+ html = `
+ ${html}
+ <span class="avatar avatar-small">
+ <div class="avatar-frame standard-image avatar-extra-count"
+ title="${extra_nodes.map(node => node.employee_name).join(', ')}">
+ +${extra_nodes.length}
+ </div>
+ </span>
+ `;
+ }
+
+ const $node_group =
+ $(`<div class="node-group card cursor-pointer" data-sibling=${sibling}>
+ <div class="avatar-group right overlap">
+ ${html}
+ </div>
+ </div>`);
+
+ return $node_group;
+ }
+
+ get_avatar(node) {
+ return `<span class="avatar avatar-small" title="${node.employee_name}">
+ <span class="avatar-frame" src=${node.image} style="background-image: url(${node.image})"></span>
+ </span>`
+ }
+
+ remove_levels_after_node(node) {
+ let level = $(`#${node.id}`).parent().parent();
+
+ level = $('.hierarchy-mobile > li:eq('+ (level.index()) + ')');
+ level.nextAll('li').remove();
+
+ let current_node = level.find(`#${node.id}`);
+ let node_object = this.nodes[node.id];
+
+ current_node.removeClass('active-child active-path');
+ node_object.expanded = 0;
+ node_object.$children = undefined;
+
+ level.empty().append(current_node);
+ }
+}
\ 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
index be29645..ae91a91 100644
--- a/erpnext/hr/page/organizational_chart/organizational_chart.py
+++ b/erpnext/hr/page/organizational_chart/organizational_chart.py
@@ -2,7 +2,7 @@
import frappe
@frappe.whitelist()
-def get_children(parent=None, company=None, is_root=False, is_tree=False, fields=None):
+def get_children(parent=None, company=None, exclude_node=None, is_root=False, is_tree=False, fields=None):
filters = [['status', '!=', 'Left']]
if company and company != 'All Companies':
@@ -13,6 +13,10 @@
if is_root:
parent = ''
+
+ if exclude_node:
+ filters.append(['name', '!=', exclude_node])
+
if parent and company and parent!=company:
filters.append(['reports_to', '=', parent])
else:
diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss
index 62f6ddc..02446be 100644
--- a/erpnext/public/scss/organizational_chart.scss
+++ b/erpnext/public/scss/organizational_chart.scss
@@ -6,6 +6,7 @@
padding: 0.75rem;
margin-left: 3rem;
width: 18rem;
+ overflow: hidden;
.btn-edit-node {
display: none;
@@ -207,3 +208,72 @@
.collapsed-connector {
stroke: var(--blue-300);
}
+
+// mobile
+
+.hierarchy-mobile {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 30px;
+ padding-left: 0px;
+}
+
+.hierarchy-mobile li {
+ list-style-type: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+}
+
+.mobile-node {
+ margin-left: 0;
+}
+
+.mobile-node.active-path {
+ width: 12.25rem;
+}
+
+.active-child {
+ width: 15.5rem;
+}
+
+.mobile-node .node-connections {
+ max-width: 80px;
+}
+
+.hierarchy-mobile .node-children {
+ margin-top: 16px;
+}
+
+// node group
+
+.collapsed-level {
+ margin-bottom: 16px;
+}
+
+.node-group {
+ background: white;
+ border: 1px solid var(--gray-300);
+ box-shadow: var(--shadow-sm);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ margin-left: 12px;
+ width: 5rem;
+ height: 3rem;
+ overflow: hidden;
+}
+
+.node-group .avatar-group {
+ margin-left: 0px;
+}
+
+.node-group .avatar-extra-count {
+ background-color: var(--blue-100);
+ color: var(--blue-500);
+}
+
+.node-group .avatar-frame {
+ width: 1.5rem;
+ height: 1.5rem;
+}
\ No newline at end of file