You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

607 line
14 KiB

  1. // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. // MIT License. See license.txt
  3. import Section from "./section.js";
  4. frappe.ui.form.Dashboard = class FormDashboard {
  5. constructor(opts) {
  6. $.extend(this, opts);
  7. let parent = this.tab ? this.tab.wrapper : this.frm.layout.wrapper;
  8. this.parent = $('<div class="form-dashboard">');
  9. parent.prepend(this.parent);
  10. this.setup_dashboard_sections();
  11. }
  12. setup_dashboard_sections() {
  13. this.progress_area = this.make_section({
  14. css_class: 'progress-area',
  15. hidden: 1,
  16. is_dashboard_section: 1,
  17. });
  18. this.heatmap_area = this.make_section({
  19. label: __("Overview"),
  20. css_class: 'form-heatmap',
  21. hidden: 1,
  22. is_dashboard_section: 1,
  23. body_html: `
  24. <div id="heatmap-${frappe.model.scrub(this.frm.doctype)}" class="heatmap"></div>
  25. <div class="text-muted small heatmap-message hidden"></div>
  26. `
  27. });
  28. this.chart_area = this.make_section({
  29. label: __("Graph"),
  30. css_class: 'form-graph',
  31. hidden: 1,
  32. is_dashboard_section: 1
  33. });
  34. this.stats_area_row = $(`<div class="row"></div>`);
  35. this.stats_area = this.make_section({
  36. label: __("Stats"),
  37. css_class: 'form-stats',
  38. hidden: 1,
  39. is_dashboard_section: 1,
  40. body_html: this.stats_area_row
  41. });
  42. this.transactions_area = $(`<div class="transactions"></div`);
  43. this.links_area = this.make_section({
  44. label: __("Connections"),
  45. css_class: 'form-links',
  46. hidden: 1,
  47. is_dashboard_section: 1,
  48. body_html: this.transactions_area
  49. });
  50. }
  51. make_section(df) {
  52. return new Section(this.parent, df);
  53. }
  54. reset() {
  55. // clear progress
  56. this.progress_area.body.empty();
  57. this.progress_area.hide();
  58. // clear links
  59. this.links_area.body.find('.count, .open-notification').addClass('hidden');
  60. this.links_area.hide();
  61. // clear stats
  62. this.stats_area_row.empty();
  63. this.stats_area.hide();
  64. // clear custom
  65. this.parent.find('.custom').remove();
  66. // this.hide();
  67. }
  68. add_section(body_html, label=null, css_class="custom", hidden=false) {
  69. let options = {
  70. label,
  71. css_class,
  72. hidden,
  73. body_html,
  74. make_card: true,
  75. is_dashboard_section: 1
  76. };
  77. return new Section(this.frm.layout, options).body;
  78. }
  79. add_progress(title, percent, message) {
  80. let progress_chart = this.make_progress_chart(title);
  81. if (!$.isArray(percent)) {
  82. percent = this.format_percent(title, percent);
  83. }
  84. let progress = $('<div class="progress"></div>').appendTo(progress_chart);
  85. $.each(percent, function(i, opts) {
  86. $(`<div class="progress-bar ${opts.progress_class}" style="width: ${opts.width}" title="${opts.title}"></div>`).appendTo(progress);
  87. });
  88. if (!message) message = '';
  89. $(`<p class="progress-message text-muted small">${message}</p>`).appendTo(progress_chart);
  90. this.show();
  91. return progress_chart;
  92. }
  93. show_progress(title, percent, message) {
  94. this._progress_map = this._progress_map || {};
  95. let progress_chart = this._progress_map[title];
  96. // create a new progress chart if it doesnt exist
  97. // or the previous one got detached from the DOM
  98. if (!progress_chart || progress_chart.parent().length == 0) {
  99. progress_chart = this.add_progress(title, percent, message);
  100. this._progress_map[title] = progress_chart;
  101. }
  102. if (!$.isArray(percent)) {
  103. percent = this.format_percent(title, percent);
  104. }
  105. progress_chart.find('.progress-bar').each((i, progress_bar) => {
  106. const { progress_class, width } = percent[i];
  107. $(progress_bar).css('width', width)
  108. .removeClass('progress-bar-danger progress-bar-success')
  109. .addClass(progress_class);
  110. });
  111. if (!message) message = '';
  112. progress_chart.find('.progress-message').text(message);
  113. }
  114. hide_progress(title) {
  115. if (title) {
  116. this._progress_map[title].remove();
  117. delete this._progress_map[title];
  118. } else {
  119. this._progress_map = {};
  120. this.progress_area.hide();
  121. }
  122. }
  123. format_percent(title, percent) {
  124. const percentage = cint(percent);
  125. const width = percentage < 0 ? 100 : percentage;
  126. const progress_class = percentage < 0 ? "progress-bar-danger" : "progress-bar-success";
  127. return [{
  128. title: title,
  129. width: width + '%',
  130. progress_class: progress_class
  131. }];
  132. }
  133. make_progress_chart(title) {
  134. this.progress_area.show();
  135. let progress_chart = $('<div class="progress-chart" title="'+(title || '')+'"></div>')
  136. .appendTo(this.progress_area.body);
  137. return progress_chart;
  138. }
  139. refresh() {
  140. this.reset();
  141. if (this.frm.doc.__islocal || !frappe.boot.desk_settings.dashboard) {
  142. return;
  143. }
  144. if (!this.data) {
  145. this.init_data();
  146. }
  147. let show = false;
  148. if (this.data && ((this.data.transactions || []).length
  149. || (this.data.reports || []).length)) {
  150. if (this.data.docstatus && this.frm.doc.docstatus !== this.data.docstatus) {
  151. // limited docstatus
  152. return;
  153. }
  154. this.render_links();
  155. show = true;
  156. }
  157. if (this.data.heatmap) {
  158. this.render_heatmap();
  159. show = true;
  160. }
  161. if (this.data.graph) {
  162. this.setup_graph();
  163. // show = true;
  164. }
  165. if (show) {
  166. this.show();
  167. }
  168. }
  169. after_refresh() {
  170. // show / hide new buttons (if allowed)
  171. this.links_area.body.find('.btn-new').each((i, el) => {
  172. if (this.frm.can_create($(this).attr('data-doctype'))) {
  173. $(el).removeClass('hidden');
  174. }
  175. });
  176. !this.frm.is_new() && this.set_open_count();
  177. }
  178. init_data() {
  179. this.data = this.frm.meta.__dashboard || {};
  180. if (!this.data.transactions) this.data.transactions = [];
  181. if (!this.data.internal_links) this.data.internal_links = {};
  182. this.filter_permissions();
  183. }
  184. add_transactions(opts) {
  185. // add additional data on dashboard
  186. let group_added = [];
  187. if (!Array.isArray(opts)) opts=[opts];
  188. if (!this.data) {
  189. this.init_data();
  190. }
  191. if (this.data && (this.data.transactions || []).length) {
  192. // check if label already exists, add items to it
  193. this.data.transactions.map(group => {
  194. opts.map(d => {
  195. if (d.label == group.label) {
  196. group_added.push(d.label);
  197. group.items.push(...d.items);
  198. }
  199. });
  200. });
  201. // if label not already present, add new label and items under it
  202. opts.map(d => {
  203. if (!group_added.includes(d.label)) {
  204. this.data.transactions.push(d);
  205. }
  206. });
  207. this.filter_permissions();
  208. }
  209. }
  210. filter_permissions() {
  211. // filter out transactions for which the user
  212. // does not have permission
  213. let transactions = [];
  214. (this.data.transactions || []).forEach(function(group) {
  215. let items = [];
  216. group.items.forEach(function(doctype) {
  217. if (frappe.model.can_read(doctype)) {
  218. items.push(doctype);
  219. }
  220. });
  221. // only add this group, if there is at-least
  222. // one item with permission
  223. if (items.length) {
  224. group.items = items;
  225. transactions.push(group);
  226. }
  227. });
  228. this.data.transactions = transactions;
  229. }
  230. render_links() {
  231. let me = this;
  232. this.links_area.show();
  233. this.links_area.body.find('.btn-new').addClass('hidden');
  234. if (this.data_rendered) {
  235. return;
  236. }
  237. this.data.frm = this.frm;
  238. let transactions_area_body = this.transactions_area;
  239. $(frappe.render_template('form_links', this.data))
  240. .appendTo(transactions_area_body);
  241. this.render_report_links();
  242. // bind links
  243. transactions_area_body.find(".badge-link").on('click', function() {
  244. me.open_document_list($(this).closest('.document-link'));
  245. });
  246. // bind open notifications
  247. transactions_area_body.find('.open-notification').on('click', function() {
  248. me.open_document_list($(this).parent(), true);
  249. });
  250. // bind new
  251. transactions_area_body.find('.btn-new').on('click', function() {
  252. me.frm.make_new($(this).attr('data-doctype'));
  253. });
  254. this.data_rendered = true;
  255. }
  256. render_report_links() {
  257. let parent = this.transactions_area;
  258. if (this.data.reports && this.data.reports.length) {
  259. $(frappe.render_template('report_links', this.data))
  260. .appendTo(parent);
  261. // bind reports
  262. parent.find(".report-link").on('click', (e) => {
  263. this.open_report($(e.target).parent());
  264. });
  265. }
  266. }
  267. open_report($link) {
  268. let report = $link.attr('data-report');
  269. let fieldname = this.data.non_standard_fieldnames
  270. ? (this.data.non_standard_fieldnames[report] || this.data.fieldname)
  271. : this.data.fieldname;
  272. frappe.provide('frappe.route_options');
  273. frappe.route_options[fieldname] = this.frm.doc.name;
  274. frappe.set_route("query-report", report);
  275. }
  276. open_document_list($link, show_open) {
  277. // show document list with filters
  278. let doctype = $link.attr('data-doctype'),
  279. names = $link.attr('data-names') || [];
  280. if (this.data.internal_links[doctype]) {
  281. if (names.length) {
  282. frappe.route_options = {'name': ['in', names]};
  283. } else {
  284. return false;
  285. }
  286. } else if (this.data.fieldname) {
  287. frappe.route_options = this.get_document_filter(doctype);
  288. if (show_open && frappe.ui.notifications) {
  289. frappe.ui.notifications.show_open_count_list(doctype);
  290. }
  291. }
  292. frappe.set_route("List", doctype, "List");
  293. }
  294. get_document_filter(doctype) {
  295. // return the default filter for the given document
  296. // like {"customer": frm.doc.name}
  297. let filter = {};
  298. let fieldname = this.data.non_standard_fieldnames
  299. ? (this.data.non_standard_fieldnames[doctype] || this.data.fieldname)
  300. : this.data.fieldname;
  301. if (this.data.dynamic_links && this.data.dynamic_links[fieldname]) {
  302. let dynamic_fieldname = this.data.dynamic_links[fieldname][1];
  303. filter[dynamic_fieldname] = this.data.dynamic_links[fieldname][0];
  304. }
  305. filter[fieldname] = this.frm.doc.name;
  306. return filter;
  307. }
  308. set_open_count() {
  309. if (!this.data.transactions || !this.data.fieldname) {
  310. return;
  311. }
  312. // list all items from the transaction list
  313. let items = [],
  314. me = this;
  315. this.data.transactions.forEach(function(group) {
  316. group.items.forEach(function(item) {
  317. items.push(item);
  318. });
  319. });
  320. let method = this.data.method || 'frappe.desk.notifications.get_open_count';
  321. frappe.call({
  322. type: "GET",
  323. method: method,
  324. args: {
  325. doctype: this.frm.doctype,
  326. name: this.frm.docname,
  327. items: items
  328. },
  329. callback: function(r) {
  330. if (r.message.timeline_data) {
  331. me.update_heatmap(r.message.timeline_data);
  332. }
  333. // update badges
  334. $.each(r.message.count, function(i, d) {
  335. me.frm.dashboard.set_badge_count(d.name, cint(d.open_count), cint(d.count));
  336. });
  337. // update from internal links
  338. $.each(me.data.internal_links, (doctype, link) => {
  339. let names = [];
  340. if (typeof link === 'string' || link instanceof String) {
  341. // get internal links in parent document
  342. let value = me.frm.doc[link];
  343. if (value && !names.includes(value)) {
  344. names.push(value);
  345. }
  346. } else if (Array.isArray(link)) {
  347. // get internal links in child documents
  348. let [table_fieldname, link_fieldname] = link;
  349. (me.frm.doc[table_fieldname] || []).forEach(d => {
  350. let value = d[link_fieldname];
  351. if (value && !names.includes(value)) {
  352. names.push(value);
  353. }
  354. });
  355. }
  356. me.frm.dashboard.set_badge_count(doctype, 0, names.length, names);
  357. });
  358. me.frm.dashboard_data = r.message;
  359. me.frm.trigger('dashboard_update');
  360. }
  361. });
  362. }
  363. set_badge_count(doctype, open_count, count, names) {
  364. let $link = $(this.transactions_area)
  365. .find('.document-link[data-doctype="'+doctype+'"]');
  366. if (open_count) {
  367. $link.find('.open-notification')
  368. .removeClass('hidden')
  369. .html((open_count > 99) ? '99+' : open_count);
  370. }
  371. if (count) {
  372. $link.find('.count')
  373. .removeClass('hidden')
  374. .text((count > 99) ? '99+' : count);
  375. }
  376. if (this.data.internal_links[doctype]) {
  377. if (names && names.length) {
  378. $link.attr('data-names', names ? names.join(',') : '');
  379. } else {
  380. $link.find('a').attr('disabled', true);
  381. }
  382. }
  383. }
  384. update_heatmap(data) {
  385. if (this.heatmap) {
  386. this.heatmap.update({dataPoints: data});
  387. }
  388. }
  389. // heatmap
  390. render_heatmap() {
  391. if (!this.heatmap) {
  392. this.heatmap = new frappe.Chart("#heatmap-" + frappe.model.scrub(this.frm.doctype), {
  393. type: 'heatmap',
  394. start: new Date(moment().subtract(1, 'year').toDate()),
  395. count_label: "interactions",
  396. discreteDomains: 1,
  397. radius: 3,
  398. data: {},
  399. });
  400. // center the heatmap
  401. this.heatmap_area.show();
  402. this.heatmap_area.body.find('svg').css({'margin': 'auto'});
  403. // message
  404. let heatmap_message = this.heatmap_area.body.find('.heatmap-message');
  405. if (this.data.heatmap_message) {
  406. heatmap_message.removeClass('hidden').html(this.data.heatmap_message);
  407. } else {
  408. heatmap_message.addClass('hidden');
  409. }
  410. }
  411. }
  412. add_indicator(label, color) {
  413. this.show();
  414. this.stats_area.show();
  415. // set colspan
  416. let indicators = this.stats_area_row.find('.indicator-column');
  417. let n_indicators = indicators.length + 1;
  418. let colspan;
  419. if (n_indicators > 4) {
  420. colspan = 3;
  421. } else {
  422. colspan = 12 / n_indicators;
  423. }
  424. // reset classes in existing indicators
  425. if (indicators.length) {
  426. indicators.removeClass().addClass('col-sm-'+colspan).addClass('indicator-column');
  427. }
  428. let indicator = $('<div class="col-sm-'+colspan+' indicator-column"><span class="indicator '+color+'">'
  429. +label+'</span></div>').appendTo(this.stats_area_row);
  430. return indicator;
  431. }
  432. // graphs
  433. setup_graph() {
  434. let me = this;
  435. let method = this.data.graph_method;
  436. let args = {
  437. doctype: this.frm.doctype,
  438. docname: this.frm.doc.name,
  439. };
  440. $.extend(args, this.data.graph_method_args);
  441. frappe.call({
  442. type: "GET",
  443. method: method,
  444. args: args,
  445. callback: function(r) {
  446. if (r.message) {
  447. me.render_graph(r.message);
  448. me.show();
  449. } else {
  450. me.hide();
  451. }
  452. }
  453. });
  454. }
  455. render_graph(args) {
  456. this.chart_area.show();
  457. this.chart_area.body.empty();
  458. $.extend({
  459. type: 'line',
  460. colors: ['green'],
  461. truncateLegends: 1,
  462. axisOptions: {
  463. shortenYAxisNumbers: 1
  464. }
  465. }, args);
  466. this.show();
  467. this.chart = new frappe.Chart('.form-graph', args);
  468. if (!this.chart) {
  469. this.hide();
  470. }
  471. }
  472. show() {
  473. this.toggle_visibility(true);
  474. }
  475. hide() {
  476. this.toggle_visibility(false);
  477. }
  478. toggle_visibility(show) {
  479. this.parent.toggleClass('visible-section', show);
  480. this.parent.toggleClass('empty-section', !show);
  481. }
  482. // TODO: Review! code related to headline should be the part of layout/form
  483. set_headline(html, color) {
  484. this.frm.layout.show_message(html, color);
  485. }
  486. clear_headline() {
  487. this.frm.layout.show_message();
  488. }
  489. add_comment(text, alert_class, permanent) {
  490. this.set_headline_alert(text, alert_class);
  491. if (!permanent) {
  492. setTimeout(() => {
  493. this.clear_headline();
  494. }, 10000);
  495. }
  496. }
  497. clear_comment() {
  498. this.clear_headline();
  499. }
  500. set_headline_alert(text, color) {
  501. if (text) {
  502. this.set_headline(`<div>${text}</div>`, color);
  503. } else {
  504. this.clear_headline();
  505. }
  506. }
  507. };