From 519cc0997978761b0c6a87abfda8db2d5df14c23 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 10 Aug 2017 11:22:03 +0530 Subject: [PATCH 1/7] [wip] New POS UI --- erpnext/accounts/page/pos/pos.js | 3 +- erpnext/public/css/pos.css | 53 ++ erpnext/public/less/pos.less | 81 +++ .../selling/page/point_of_sale/__init__.py | 0 .../page/point_of_sale/point_of_sale.js | 478 ++++++++++++++++++ .../page/point_of_sale/point_of_sale.json | 20 + 6 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 erpnext/public/css/pos.css create mode 100644 erpnext/public/less/pos.less create mode 100644 erpnext/selling/page/point_of_sale/__init__.py create mode 100644 erpnext/selling/page/point_of_sale/point_of_sale.js create mode 100644 erpnext/selling/page/point_of_sale/point_of_sale.json diff --git a/erpnext/accounts/page/pos/pos.js b/erpnext/accounts/page/pos/pos.js index d69a3066706..599372411a0 100644 --- a/erpnext/accounts/page/pos/pos.js +++ b/erpnext/accounts/page/pos/pos.js @@ -8,7 +8,8 @@ frappe.pages['pos'].on_page_load = function (wrapper) { single_column: true }); - wrapper.pos = new erpnext.pos.PointOfSale(wrapper) + wrapper.pos = new erpnext.pos.PointOfSale(wrapper); + cur_pos = wrapper.pos; } frappe.pages['pos'].refresh = function (wrapper) { diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css new file mode 100644 index 00000000000..d44b17caafa --- /dev/null +++ b/erpnext/public/css/pos.css @@ -0,0 +1,53 @@ +.pos { + padding: 15px; +} +.customer-container { + padding: 0 15px; + display: inline-block; + width: 39%; + vertical-align: top; +} +.item-container { + padding: 0 15px; + display: inline-block; + width: 60%; + vertical-align: top; +} +.item-group-field { + margin-left: 15px; +} +.cart-wrapper .list-item__content:not(:first-child) { + justify-content: flex-end; +} +.cart-items { + height: 200px; + overflow: auto; +} +.fields { + display: flex; +} +.pos-items-wrapper { + max-height: 480px; + overflow: auto; +} +.pos-item-wrapper { + height: 250px; +} +.image-view-container { + display: block; +} +.image-view-container .image-field { + height: auto; +} +.empty-state { + height: 100%; + position: relative; +} +.empty-state span { + position: absolute; + color: #8D99A6; + font-size: 12px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/erpnext/public/less/pos.less b/erpnext/public/less/pos.less new file mode 100644 index 00000000000..c94f8b59f5d --- /dev/null +++ b/erpnext/public/less/pos.less @@ -0,0 +1,81 @@ +@import "../../../../frappe/frappe/public/less/variables.less"; + +.pos { + // display: flex; + padding: 15px; +} + +.customer-container { + padding: 0 15px; + // flex: 2; + display: inline-block; + width: 39%; + vertical-align: top; +} + +.item-container { + padding: 0 15px; + // flex: 3; + display: inline-block; + width: 60%; + vertical-align: top; +} + +.item-group-field { + margin-left: 15px; +} + +.cart-wrapper { + .list-item__content:not(:first-child) { + justify-content: flex-end; + } +} + +.cart-items { + height: 200px; + overflow: auto; + + // .list-item { + // background-color: @light-yellow; + // transition: background-color 1s linear; + // } + + // .list-item.added { + // background-color: white; + // } +} + +.fields { + display: flex; +} + +.pos-items-wrapper { + max-height: 480px; + overflow: auto; +} + +.pos-item-wrapper { + height: 250px; +} + +.image-view-container { + display: block; +} + +.image-view-container .image-field { + height: auto; +} + +.empty-state { + height: 100%; + position: relative; + + span { + position: absolute; + color: @text-muted; + font-size: @text-medium; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/__init__.py b/erpnext/selling/page/point_of_sale/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js new file mode 100644 index 00000000000..c9ef30e7eb8 --- /dev/null +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -0,0 +1,478 @@ +frappe.pages['point-of-sale'].on_page_load = function(wrapper) { + var page = frappe.ui.make_app_page({ + parent: wrapper, + title: 'Point of Sale', + single_column: true + }); + + wrapper.pos = new erpnext.PointOfSale(wrapper); + cur_pos = wrapper.pos; +} + +erpnext.PointOfSale = class PointOfSale { + constructor(wrapper) { + this.wrapper = $(wrapper).find('.layout-main-section'); + this.page = wrapper.page; + + const assets = [ + 'assets/frappe/js/lib/clusterize.js', + 'assets/erpnext/css/pos.css' + ]; + + frappe.require(assets, () => { + this.prepare(); + this.make(); + this.bind_events(); + }); + } + + prepare() { + this.set_online_status(); + this.prepare_menu(); + } + + make() { + this.make_dom(); + this.make_customer_field(); + this.make_cart(); + this.make_items(); + } + + set_online_status() { + this.connection_status = false; + this.page.set_indicator(__("Offline"), "grey"); + frappe.call({ + method: "frappe.handler.ping", + callback: r => { + if (r.message) { + this.connection_status = true; + this.page.set_indicator(__("Online"), "green"); + } + } + }); + } + + prepare_menu() { + this.page.clear_menu(); + + // for mobile + this.page.add_menu_item(__("Pay"), function () { + // + }).addClass('visible-xs'); + + this.page.add_menu_item(__("New Sales Invoice"), function () { + // + }) + + this.page.add_menu_item(__("Sync Master Data"), function () { + // + }); + + this.page.add_menu_item(__("Sync Offline Invoices"), function () { + // + }); + + this.page.add_menu_item(__("POS Profile"), function () { + frappe.set_route('List', 'POS Profile'); + }); + } + + make_dom() { + this.wrapper.append(` +
+
+
+
+
+
+
+
+ +
+
+ `); + } + + make_customer_field() { + this.customer_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Link', + label: 'Customer', + options: 'Customer' + }, + parent: this.wrapper.find('.customer-field'), + render_input: true + }); + } + + make_cart() { + this.cart = new erpnext.POSCart(this.wrapper.find('.cart-wrapper')); + } + + make_items() { + this.items = new erpnext.POSItems(this.wrapper.find('.item-container'), { + item_click: (item_code) => this.add_item_to_cart(item_code) + }); + } + + add_item_to_cart(item_code) { + const item = this.items.get(item_code); + this.cart.add_item(item); + } + + bind_events() { + + } + + make_sales_invoice_frm() { + const dt = 'Sales Invoice'; + frappe.model.with_doctype(dt, function() { + const page = $('
'); + const frm = new _f.Frm(dt, page, false); + const name = frappe.model.make_new_doc_and_get_name(dt, true); + frm.refresh(name); + }); + } +} + +erpnext.POSCart = class POSCart { + constructor(wrapper) { + this.wrapper = wrapper; + this.items = {}; + this.make(); + } + + make() { + this.wrapper.append(` +
+
+
${__('Item Name')}
+
${__('Quantity')}
+
${__('Discount')}
+
${__('Rate')}
+
+
+
+ No Items added to cart +
+
+
+ `); + } + + add_item(item) { + const { item_code } = item; + const _item = this.items[item_code]; + + if (_item) { + // exists, increase quantity + _item.quantity += 1; + this.update_quantity(_item); + } else { + // add it to this.items + const _item = { + doc: item, + quantity: 1, + discount: 2, + rate: 2 + } + Object.assign(this.items, { + [item_code]: _item + }); + this.add_item_to_cart(_item); + } + } + + add_item_to_cart(item) { + this.wrapper.find('.cart-items .empty-state').hide(); + const $item = $(this.get_item_html(item)) + $item.appendTo(this.wrapper.find('.cart-items')); + // $item.addClass('added'); + // this.wrapper.find('.cart-items').append(this.get_item_html(item)) + } + + update_quantity(item) { + this.wrapper.find(`.list-item[data-item-name="${item.doc.item_code}"] .quantity`) + .text(item.quantity); + } + + remove_item(item_code) { + delete this.items[item_code]; + + // this.refresh(); + } + + refresh() { + const item_codes = Object.keys(this.items); + const html = item_codes + .map(item_code => this.get_item_html(item_code)) + .join(""); + this.wrapper.find('.cart-items').html(html); + } + + get_item_html(_item) { + + let item; + if (typeof _item === "object") { + item = _item; + } + else if (typeof _item === "string") { + item = this.items[_item]; + } + + return ` +
+
+ ${item.doc.item_name} +
+
+ ${item.quantity} +
+
+ ${item.discount} +
+
+ ${item.rate} +
+
+ `; + } +} + +erpnext.POSItems = class POSItems { + constructor(wrapper, events) { + this.wrapper = wrapper; + this.items = {}; + this.make_dom(); + this.make_fields(); + + this.init_clusterize(); + this.bind_events(events); + + // bootstrap with 20 items + this.get_items() + .then(items => { + this.items = items + }) + .then(() => this.render_items()); + } + + make_dom() { + this.wrapper.html(` +
+
+
+
+
+
+
+
+ `); + + this.items_wrapper = this.wrapper.find('.items-wrapper'); + this.items_wrapper.append(` +
+
+
+
+ `); + } + + make_fields() { + this.search_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Data', + label: 'Search Item', + onchange: (e) => { + const search_term = e.target.value; + this.filter_items(search_term); + } + }, + parent: this.wrapper.find('.search-field'), + render_input: true, + }); + + this.item_group_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Select', + label: 'Item Group', + options: [ + 'All Item Groups', + 'Raw Materials', + 'Finished Goods' + ], + default: 'All Item Groups' + }, + parent: this.wrapper.find('.item-group-field'), + render_input: true + }); + } + + init_clusterize() { + this.clusterize = new Clusterize({ + scrollElem: this.wrapper.find('.pos-items-wrapper')[0], + contentElem: this.wrapper.find('.pos-items')[0], + rows_in_block: 6 + }); + } + + render_items(items) { + let _items = items || this.items; + + const all_items = Object.values(_items).map(item => this.get_item_html(item)); + let row_items = []; + + const row_container = '
'; + let curr_row = row_container; + for (let i=0; i < all_items.length; i++) { + // wrap 4 items in a div to emulate + // a row for clusterize + if(i % 4 === 0 && i !== 0) { + curr_row += '
'; + row_items.push(curr_row); + curr_row = row_container; + } + curr_row += all_items[i]; + } + + this.clusterize.update(row_items); + } + + filter_items(search_term) { + search_term = search_term.toLowerCase(); + + const filtered_items = + Object.values(this.items) + .filter( + item => item.item_name.toLowerCase().includes(search_term) + ); + this.render_items(filtered_items); + } + + bind_events(events) { + this.wrapper.on('click', '.pos-item-wrapper', function(e) { + const $item = $(this); + const item_code = $item.attr('data-item-code'); + events.item_click.apply(null, [item_code]); + }); + } + + get(item_code) { + return this.items[item_code]; + } + + get_all() { + return this.items; + } + + get_item_html(item) { + const { item_code, item_name, image: item_image, item_stock=0, item_price=0} = item; + const item_title = item_name || item_code; + + const template = ` + + `; + + // const template = ` + + //
+ //
+ // ${item_name} + // Stock: ${item_stock} + //
+ //
+ //
+ // ${item_image ? + // `${item_title}` : + // ` + // ${frappe.get_abbr(item_title)} + // ` + // } + //
+ //
+ //
+ + // `; + + return template; + } + + get_items(start = 0, page_length = 2000) { + return new Promise(res => { + frappe.call({ + method: "frappe.desk.reportview.get", + type: "GET", + args: { + doctype: "Item", + fields: [ + "`tabItem`.`name`", + "`tabItem`.`owner`", + "`tabItem`.`docstatus`", + "`tabItem`.`modified`", + "`tabItem`.`modified_by`", + "`tabItem`.`item_name`", + "`tabItem`.`item_code`", + "`tabItem`.`disabled`", + "`tabItem`.`item_group`", + "`tabItem`.`stock_uom`", + "`tabItem`.`image`", + "`tabItem`.`variant_of`", + "`tabItem`.`has_variants`", + "`tabItem`.`end_of_life`", + "`tabItem`.`total_projected_qty`" + ], + order_by: "`tabItem`.`modified` desc", + page_length: page_length, + start: start + } + }) + .then(r => { + const data = r.message; + const items = frappe.utils.dict(data.keys, data.values); + + // convert to key, value + let items_dict = {}; + items.map(item => { + items_dict[item.item_code] = item; + }); + + res(items_dict); + }); + }); + } +} \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.json b/erpnext/selling/page/point_of_sale/point_of_sale.json new file mode 100644 index 00000000000..1e348c09af8 --- /dev/null +++ b/erpnext/selling/page/point_of_sale/point_of_sale.json @@ -0,0 +1,20 @@ +{ + "content": null, + "creation": "2017-08-07 17:08:56.737947", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2017-08-07 17:08:56.737947", + "modified_by": "Administrator", + "module": "Selling", + "name": "point-of-sale", + "owner": "Administrator", + "page_name": "Point of Sale", + "restrict_to_domain": "Retail", + "roles": [], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Point of Sale" +} \ No newline at end of file From 65c4bd6db60c06b18c07dabcc15c4ec16dc7964d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 10 Aug 2017 17:17:34 +0530 Subject: [PATCH 2/7] Add clusterize, move customer field to POSCart, get POS profile --- erpnext/public/css/pos.css | 2 +- erpnext/public/js/pos/clusterize.js | 329 ++++++++++++++++++ erpnext/public/less/pos.less | 2 +- .../page/point_of_sale/point_of_sale.js | 167 +++++---- 4 files changed, 428 insertions(+), 72 deletions(-) create mode 100644 erpnext/public/js/pos/clusterize.js diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css index d44b17caafa..4ba00ba2277 100644 --- a/erpnext/public/css/pos.css +++ b/erpnext/public/css/pos.css @@ -1,7 +1,7 @@ .pos { padding: 15px; } -.customer-container { +.cart-container { padding: 0 15px; display: inline-block; width: 39%; diff --git a/erpnext/public/js/pos/clusterize.js b/erpnext/public/js/pos/clusterize.js new file mode 100644 index 00000000000..6d331ba7618 --- /dev/null +++ b/erpnext/public/js/pos/clusterize.js @@ -0,0 +1,329 @@ +/*! Clusterize.js - v0.17.6 - 2017-03-05 +* http://NeXTs.github.com/Clusterize.js/ +* Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */ + +;(function(name, definition) { + if (typeof module != 'undefined') module.exports = definition(); + else if (typeof define == 'function' && typeof define.amd == 'object') define(definition); + else this[name] = definition(); +}('Clusterize', function() { + "use strict" + + // detect ie9 and lower + // https://gist.github.com/padolsey/527683#comment-786682 + var ie = (function(){ + for( var v = 3, + el = document.createElement('b'), + all = el.all || []; + el.innerHTML = '', + all[0]; + ){} + return v > 4 ? v : document.documentMode; + }()), + is_mac = navigator.platform.toLowerCase().indexOf('mac') + 1; + var Clusterize = function(data) { + if( ! (this instanceof Clusterize)) + return new Clusterize(data); + var self = this; + + var defaults = { + rows_in_block: 50, + blocks_in_cluster: 4, + tag: null, + show_no_data_row: true, + no_data_class: 'clusterize-no-data', + no_data_text: 'No data', + keep_parity: true, + callbacks: {} + } + + // public parameters + self.options = {}; + var options = ['rows_in_block', 'blocks_in_cluster', 'show_no_data_row', 'no_data_class', 'no_data_text', 'keep_parity', 'tag', 'callbacks']; + for(var i = 0, option; option = options[i]; i++) { + self.options[option] = typeof data[option] != 'undefined' && data[option] != null + ? data[option] + : defaults[option]; + } + + var elems = ['scroll', 'content']; + for(var i = 0, elem; elem = elems[i]; i++) { + self[elem + '_elem'] = data[elem + 'Id'] + ? document.getElementById(data[elem + 'Id']) + : data[elem + 'Elem']; + if( ! self[elem + '_elem']) + throw new Error("Error! Could not find " + elem + " element"); + } + + // tabindex forces the browser to keep focus on the scrolling list, fixes #11 + if( ! self.content_elem.hasAttribute('tabindex')) + self.content_elem.setAttribute('tabindex', 0); + + // private parameters + var rows = isArray(data.rows) + ? data.rows + : self.fetchMarkup(), + cache = {}, + scroll_top = self.scroll_elem.scrollTop; + + // append initial data + self.insertToDOM(rows, cache); + + // restore the scroll position + self.scroll_elem.scrollTop = scroll_top; + + // adding scroll handler + var last_cluster = false, + scroll_debounce = 0, + pointer_events_set = false, + scrollEv = function() { + // fixes scrolling issue on Mac #3 + if (is_mac) { + if( ! pointer_events_set) self.content_elem.style.pointerEvents = 'none'; + pointer_events_set = true; + clearTimeout(scroll_debounce); + scroll_debounce = setTimeout(function () { + self.content_elem.style.pointerEvents = 'auto'; + pointer_events_set = false; + }, 50); + } + if (last_cluster != (last_cluster = self.getClusterNum())) + self.insertToDOM(rows, cache); + if (self.options.callbacks.scrollingProgress) + self.options.callbacks.scrollingProgress(self.getScrollProgress()); + }, + resize_debounce = 0, + resizeEv = function() { + clearTimeout(resize_debounce); + resize_debounce = setTimeout(self.refresh, 100); + } + on('scroll', self.scroll_elem, scrollEv); + on('resize', window, resizeEv); + + // public methods + self.destroy = function(clean) { + off('scroll', self.scroll_elem, scrollEv); + off('resize', window, resizeEv); + self.html((clean ? self.generateEmptyRow() : rows).join('')); + } + self.refresh = function(force) { + if(self.getRowsHeight(rows) || force) self.update(rows); + } + self.update = function(new_rows) { + rows = isArray(new_rows) + ? new_rows + : []; + var scroll_top = self.scroll_elem.scrollTop; + // fixes #39 + if(rows.length * self.options.item_height < scroll_top) { + self.scroll_elem.scrollTop = 0; + last_cluster = 0; + } + self.insertToDOM(rows, cache); + self.scroll_elem.scrollTop = scroll_top; + } + self.clear = function() { + self.update([]); + } + self.getRowsAmount = function() { + return rows.length; + } + self.getScrollProgress = function() { + return this.options.scroll_top / (rows.length * this.options.item_height) * 100 || 0; + } + + var add = function(where, _new_rows) { + var new_rows = isArray(_new_rows) + ? _new_rows + : []; + if( ! new_rows.length) return; + rows = where == 'append' + ? rows.concat(new_rows) + : new_rows.concat(rows); + self.insertToDOM(rows, cache); + } + self.append = function(rows) { + add('append', rows); + } + self.prepend = function(rows) { + add('prepend', rows); + } + } + + Clusterize.prototype = { + constructor: Clusterize, + // fetch existing markup + fetchMarkup: function() { + var rows = [], rows_nodes = this.getChildNodes(this.content_elem); + while (rows_nodes.length) { + rows.push(rows_nodes.shift().outerHTML); + } + return rows; + }, + // get tag name, content tag name, tag height, calc cluster height + exploreEnvironment: function(rows, cache) { + var opts = this.options; + opts.content_tag = this.content_elem.tagName.toLowerCase(); + if( ! rows.length) return; + if(ie && ie <= 9 && ! opts.tag) opts.tag = rows[0].match(/<([^>\s/]*)/)[1].toLowerCase(); + if(this.content_elem.children.length <= 1) cache.data = this.html(rows[0] + rows[0] + rows[0]); + if( ! opts.tag) opts.tag = this.content_elem.children[0].tagName.toLowerCase(); + this.getRowsHeight(rows); + }, + getRowsHeight: function(rows) { + var opts = this.options, + prev_item_height = opts.item_height; + opts.cluster_height = 0; + if( ! rows.length) return; + var nodes = this.content_elem.children; + var node = nodes[Math.floor(nodes.length / 2)]; + opts.item_height = node.offsetHeight; + // consider table's border-spacing + if(opts.tag == 'tr' && getStyle('borderCollapse', this.content_elem) != 'collapse') + opts.item_height += parseInt(getStyle('borderSpacing', this.content_elem), 10) || 0; + // consider margins (and margins collapsing) + if(opts.tag != 'tr') { + var marginTop = parseInt(getStyle('marginTop', node), 10) || 0; + var marginBottom = parseInt(getStyle('marginBottom', node), 10) || 0; + opts.item_height += Math.max(marginTop, marginBottom); + } + opts.block_height = opts.item_height * opts.rows_in_block; + opts.rows_in_cluster = opts.blocks_in_cluster * opts.rows_in_block; + opts.cluster_height = opts.blocks_in_cluster * opts.block_height; + return prev_item_height != opts.item_height; + }, + // get current cluster number + getClusterNum: function () { + this.options.scroll_top = this.scroll_elem.scrollTop; + return Math.floor(this.options.scroll_top / (this.options.cluster_height - this.options.block_height)) || 0; + }, + // generate empty row if no data provided + generateEmptyRow: function() { + var opts = this.options; + if( ! opts.tag || ! opts.show_no_data_row) return []; + var empty_row = document.createElement(opts.tag), + no_data_content = document.createTextNode(opts.no_data_text), td; + empty_row.className = opts.no_data_class; + if(opts.tag == 'tr') { + td = document.createElement('td'); + // fixes #53 + td.colSpan = 100; + td.appendChild(no_data_content); + } + empty_row.appendChild(td || no_data_content); + return [empty_row.outerHTML]; + }, + // generate cluster for current scroll position + generate: function (rows, cluster_num) { + var opts = this.options, + rows_len = rows.length; + if (rows_len < opts.rows_in_block) { + return { + top_offset: 0, + bottom_offset: 0, + rows_above: 0, + rows: rows_len ? rows : this.generateEmptyRow() + } + } + var items_start = Math.max((opts.rows_in_cluster - opts.rows_in_block) * cluster_num, 0), + items_end = items_start + opts.rows_in_cluster, + top_offset = Math.max(items_start * opts.item_height, 0), + bottom_offset = Math.max((rows_len - items_end) * opts.item_height, 0), + this_cluster_rows = [], + rows_above = items_start; + if(top_offset < 1) { + rows_above++; + } + for (var i = items_start; i < items_end; i++) { + rows[i] && this_cluster_rows.push(rows[i]); + } + return { + top_offset: top_offset, + bottom_offset: bottom_offset, + rows_above: rows_above, + rows: this_cluster_rows + } + }, + renderExtraTag: function(class_name, height) { + var tag = document.createElement(this.options.tag), + clusterize_prefix = 'clusterize-'; + tag.className = [clusterize_prefix + 'extra-row', clusterize_prefix + class_name].join(' '); + height && (tag.style.height = height + 'px'); + return tag.outerHTML; + }, + // if necessary verify data changed and insert to DOM + insertToDOM: function(rows, cache) { + // explore row's height + if( ! this.options.cluster_height) { + this.exploreEnvironment(rows, cache); + } + var data = this.generate(rows, this.getClusterNum()), + this_cluster_rows = data.rows.join(''), + this_cluster_content_changed = this.checkChanges('data', this_cluster_rows, cache), + top_offset_changed = this.checkChanges('top', data.top_offset, cache), + only_bottom_offset_changed = this.checkChanges('bottom', data.bottom_offset, cache), + callbacks = this.options.callbacks, + layout = []; + + if(this_cluster_content_changed || top_offset_changed) { + if(data.top_offset) { + this.options.keep_parity && layout.push(this.renderExtraTag('keep-parity')); + layout.push(this.renderExtraTag('top-space', data.top_offset)); + } + layout.push(this_cluster_rows); + data.bottom_offset && layout.push(this.renderExtraTag('bottom-space', data.bottom_offset)); + callbacks.clusterWillChange && callbacks.clusterWillChange(); + this.html(layout.join('')); + this.options.content_tag == 'ol' && this.content_elem.setAttribute('start', data.rows_above); + callbacks.clusterChanged && callbacks.clusterChanged(); + } else if(only_bottom_offset_changed) { + this.content_elem.lastChild.style.height = data.bottom_offset + 'px'; + } + }, + // unfortunately ie <= 9 does not allow to use innerHTML for table elements, so make a workaround + html: function(data) { + var content_elem = this.content_elem; + if(ie && ie <= 9 && this.options.tag == 'tr') { + var div = document.createElement('div'), last; + div.innerHTML = '' + data + '
'; + while((last = content_elem.lastChild)) { + content_elem.removeChild(last); + } + var rows_nodes = this.getChildNodes(div.firstChild.firstChild); + while (rows_nodes.length) { + content_elem.appendChild(rows_nodes.shift()); + } + } else { + content_elem.innerHTML = data; + } + }, + getChildNodes: function(tag) { + var child_nodes = tag.children, nodes = []; + for (var i = 0, ii = child_nodes.length; i < ii; i++) { + nodes.push(child_nodes[i]); + } + return nodes; + }, + checkChanges: function(type, value, cache) { + var changed = value != cache[type]; + cache[type] = value; + return changed; + } + } + + // support functions + function on(evt, element, fnc) { + return element.addEventListener ? element.addEventListener(evt, fnc, false) : element.attachEvent("on" + evt, fnc); + } + function off(evt, element, fnc) { + return element.removeEventListener ? element.removeEventListener(evt, fnc, false) : element.detachEvent("on" + evt, fnc); + } + function isArray(arr) { + return Object.prototype.toString.call(arr) === '[object Array]'; + } + function getStyle(prop, elem) { + return window.getComputedStyle ? window.getComputedStyle(elem)[prop] : elem.currentStyle[prop]; + } + + return Clusterize; +})); \ No newline at end of file diff --git a/erpnext/public/less/pos.less b/erpnext/public/less/pos.less index c94f8b59f5d..e627dbc080a 100644 --- a/erpnext/public/less/pos.less +++ b/erpnext/public/less/pos.less @@ -5,7 +5,7 @@ padding: 15px; } -.customer-container { +.cart-container { padding: 0 15px; // flex: 2; display: inline-block; diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index c9ef30e7eb8..fb3f666e8aa 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -15,25 +15,26 @@ erpnext.PointOfSale = class PointOfSale { this.page = wrapper.page; const assets = [ - 'assets/frappe/js/lib/clusterize.js', + 'assets/erpnext/js/pos/clusterize.js', 'assets/erpnext/css/pos.css' ]; frappe.require(assets, () => { - this.prepare(); - this.make(); - this.bind_events(); + this.prepare().then(() => { + this.make(); + this.bind_events(); + }); }); } prepare() { this.set_online_status(); this.prepare_menu(); + return this.get_pos_profile(); } make() { this.make_dom(); - this.make_customer_field(); this.make_cart(); this.make_items(); } @@ -52,6 +53,66 @@ erpnext.PointOfSale = class PointOfSale { }); } + make_dom() { + this.wrapper.append(` +
+
+ +
+
+ +
+
+ `); + } + + make_cart() { + this.cart = new erpnext.POSCart(this.wrapper.find('.cart-container')); + } + + make_items() { + this.items = new erpnext.POSItems({ + wrapper: this.wrapper.find('.item-container'), + pos_profile: this.pos_profile, + events: { + item_click: (item_code) => this.add_item_to_cart(item_code) + } + }); + } + + add_item_to_cart(item_code) { + const item = this.items.get(item_code); + this.cart.add_item(item); + } + + bind_events() { + + } + + get_pos_profile() { + return frappe.call({ + method: 'erpnext.stock.get_item_details.get_pos_profile', + args: { + company: frappe.sys_defaults.company + } + }).then(r => { + this.pos_profile = r.message; + }); + } + + make_sales_invoice_frm() { + const dt = 'Sales Invoice'; + return new Promise(resolve => { + frappe.model.with_doctype(dt, function() { + const page = $('
'); + const frm = new _f.Frm(dt, page, false); + const name = frappe.model.make_new_doc_and_get_name(dt, true); + frm.refresh(name); + resolve(frm); + }); + }); + } + prepare_menu() { this.page.clear_menu(); @@ -76,19 +137,38 @@ erpnext.PointOfSale = class PointOfSale { frappe.set_route('List', 'POS Profile'); }); } +} + +erpnext.POSCart = class POSCart { + constructor(wrapper) { + this.wrapper = wrapper; + this.items = {}; + this.make(); + } + + make() { + this.make_dom(); + this.make_customer_field(); + } make_dom() { this.wrapper.append(` -
-
-
+
+
+
+
+
+
${__('Item Name')}
+
${__('Quantity')}
+
${__('Discount')}
+
${__('Rate')}
-
+
+
+ No Items added to cart +
-
-
- -
+
`); } @@ -105,61 +185,6 @@ erpnext.PointOfSale = class PointOfSale { }); } - make_cart() { - this.cart = new erpnext.POSCart(this.wrapper.find('.cart-wrapper')); - } - - make_items() { - this.items = new erpnext.POSItems(this.wrapper.find('.item-container'), { - item_click: (item_code) => this.add_item_to_cart(item_code) - }); - } - - add_item_to_cart(item_code) { - const item = this.items.get(item_code); - this.cart.add_item(item); - } - - bind_events() { - - } - - make_sales_invoice_frm() { - const dt = 'Sales Invoice'; - frappe.model.with_doctype(dt, function() { - const page = $('
'); - const frm = new _f.Frm(dt, page, false); - const name = frappe.model.make_new_doc_and_get_name(dt, true); - frm.refresh(name); - }); - } -} - -erpnext.POSCart = class POSCart { - constructor(wrapper) { - this.wrapper = wrapper; - this.items = {}; - this.make(); - } - - make() { - this.wrapper.append(` -
-
-
${__('Item Name')}
-
${__('Quantity')}
-
${__('Discount')}
-
${__('Rate')}
-
-
-
- No Items added to cart -
-
-
- `); - } - add_item(item) { const { item_code } = item; const _item = this.items[item_code]; @@ -240,9 +265,11 @@ erpnext.POSCart = class POSCart { } erpnext.POSItems = class POSItems { - constructor(wrapper, events) { + constructor({wrapper, pos_profile, events}) { this.wrapper = wrapper; + this.pos_profile = pos_profile; this.items = {}; + this.make_dom(); this.make_fields(); @@ -432,7 +459,7 @@ erpnext.POSItems = class POSItems { return template; } - get_items(start = 0, page_length = 2000) { + get_items(start = 0, page_length = 20) { return new Promise(res => { frappe.call({ method: "frappe.desk.reportview.get", From f7c7ff4aae3441b050425a7b4dee42adba36b6d6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 10 Aug 2017 18:28:05 +0530 Subject: [PATCH 3/7] Styling for search fields --- erpnext/public/css/pos.css | 11 +++++++- erpnext/public/less/pos.less | 22 ++++++++------- .../page/point_of_sale/point_of_sale.js | 27 +++++++++++++------ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css index 4ba00ba2277..81b109838fe 100644 --- a/erpnext/public/css/pos.css +++ b/erpnext/public/css/pos.css @@ -13,7 +13,14 @@ width: 60%; vertical-align: top; } +.search-field { + width: 60%; +} +.search-field input::placeholder { + font-size: 12px; +} .item-group-field { + width: 40%; margin-left: 15px; } .cart-wrapper .list-item__content:not(:first-child) { @@ -31,7 +38,9 @@ overflow: auto; } .pos-item-wrapper { - height: 250px; + display: flex; + flex-direction: column; + justify-content: space-between; } .image-view-container { display: block; diff --git a/erpnext/public/less/pos.less b/erpnext/public/less/pos.less index e627dbc080a..1ae0dfd993e 100644 --- a/erpnext/public/less/pos.less +++ b/erpnext/public/less/pos.less @@ -21,7 +21,16 @@ vertical-align: top; } +.search-field { + width: 60%; + + input::placeholder { + font-size: @text-medium; + } +} + .item-group-field { + width: 40%; margin-left: 15px; } @@ -34,15 +43,6 @@ .cart-items { height: 200px; overflow: auto; - - // .list-item { - // background-color: @light-yellow; - // transition: background-color 1s linear; - // } - - // .list-item.added { - // background-color: white; - // } } .fields { @@ -55,7 +55,9 @@ } .pos-item-wrapper { - height: 250px; + display: flex; + flex-direction: column; + justify-content: space-between; } .image-view-container { diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index fb3f666e8aa..1fb8c1ec0b1 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -306,19 +306,28 @@ erpnext.POSItems = class POSItems { } make_fields() { + // Search field this.search_field = frappe.ui.form.make_control({ df: { fieldtype: 'Data', - label: 'Search Item', - onchange: (e) => { - const search_term = e.target.value; - this.filter_items(search_term); - } + label: 'Search Item (Ctrl + I)', + placeholder: 'Search by item code, serial number, batch no or barcode' }, parent: this.wrapper.find('.search-field'), render_input: true, }); + frappe.ui.keys.on('ctrl+i', () => { + this.search_field.set_focus(); + }); + + this.search_field.$input.on('input', (e) => { + const search_term = e.target.value; + this.filter_items(search_term); + }); + + + // Item group field this.item_group_field = frappe.ui.form.make_control({ df: { fieldtype: 'Select', @@ -370,9 +379,11 @@ erpnext.POSItems = class POSItems { const filtered_items = Object.values(this.items) - .filter( - item => item.item_name.toLowerCase().includes(search_term) - ); + .filter(item => { + return item.item_code.toLowerCase().includes(search_term) || + item.item_name.toLowerCase().includes(search_term) + }); + this.render_items(filtered_items); } From 5a130879394c2db6403cd6e880b5399d5934b985 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 11 Aug 2017 15:49:23 +0530 Subject: [PATCH 4/7] more styling --- erpnext/public/css/pos.css | 5 ++++- erpnext/public/less/pos.less | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css index 81b109838fe..399613d4970 100644 --- a/erpnext/public/css/pos.css +++ b/erpnext/public/css/pos.css @@ -35,7 +35,10 @@ } .pos-items-wrapper { max-height: 480px; - overflow: auto; + overflow-y: auto; +} +.pos-items { + overflow: hidden; } .pos-item-wrapper { display: flex; diff --git a/erpnext/public/less/pos.less b/erpnext/public/less/pos.less index 1ae0dfd993e..9358f0a24c3 100644 --- a/erpnext/public/less/pos.less +++ b/erpnext/public/less/pos.less @@ -51,7 +51,11 @@ .pos-items-wrapper { max-height: 480px; - overflow: auto; + overflow-y: auto; +} + +.pos-items { + overflow: hidden; } .pos-item-wrapper { From 854d335ab1f086d69136b75d07a217612e517581 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 11 Aug 2017 18:44:19 +0530 Subject: [PATCH 5/7] Added functional part --- .../selling/page/point_of_sale/point_of_sale.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index 1fb8c1ec0b1..c28a5bd8c6f 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -30,6 +30,7 @@ erpnext.PointOfSale = class PointOfSale { prepare() { this.set_online_status(); this.prepare_menu(); + this.make_sales_invoice_frm() return this.get_pos_profile(); } @@ -108,6 +109,7 @@ erpnext.PointOfSale = class PointOfSale { const frm = new _f.Frm(dt, page, false); const name = frappe.model.make_new_doc_and_get_name(dt, true); frm.refresh(name); + frm.doc.items = []; resolve(frm); }); }); @@ -178,7 +180,10 @@ erpnext.POSCart = class POSCart { df: { fieldtype: 'Link', label: 'Customer', - options: 'Customer' + options: 'Customer', + onchange: (e) => { + cur_frm.set_value('customer', this.customer_field.value); + } }, parent: this.wrapper.find('.customer-field'), render_input: true @@ -195,6 +200,10 @@ erpnext.POSCart = class POSCart { this.update_quantity(_item); } else { // add it to this.items + item['qty'] = 1; + this.child = cur_frm.add_child('items', item) + cur_frm.script_manager.trigger("item_code", this.child.doctype, this.child.name); + const _item = { doc: item, quantity: 1, @@ -219,6 +228,12 @@ erpnext.POSCart = class POSCart { update_quantity(item) { this.wrapper.find(`.list-item[data-item-name="${item.doc.item_code}"] .quantity`) .text(item.quantity); + + $.each(cur_frm.doc["items"] || [], function(i, d) { + if (d.item_code == item.doc.item_code) { + frappe.model.set_value(d.doctype, d.name, "qty", d.qty + 1); + } + }); } remove_item(item_code) { From 21fc26c2a22452c7b01547ab521625b511791a77 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 14 Aug 2017 18:25:59 +0530 Subject: [PATCH 6/7] Update cart ui from cur_frm, Add number pad --- erpnext/public/css/pos.css | 42 +++ erpnext/public/less/pos.less | 44 +++ .../page/point_of_sale/point_of_sale.js | 334 ++++++++++++------ 3 files changed, 305 insertions(+), 115 deletions(-) diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css index 399613d4970..de1e097bb88 100644 --- a/erpnext/public/css/pos.css +++ b/erpnext/public/css/pos.css @@ -23,6 +23,9 @@ width: 40%; margin-left: 15px; } +.cart-wrapper { + margin-bottom: 10px; +} .cart-wrapper .list-item__content:not(:first-child) { justify-content: flex-end; } @@ -30,6 +33,10 @@ height: 200px; overflow: auto; } +.cart-items input { + height: 22px; + font-size: 12px; +} .fields { display: flex; } @@ -63,3 +70,38 @@ left: 50%; transform: translate(-50%, -50%); } +@keyframes yellow-fade { + 0% { + background-color: #fffce7; + } + 100% { + background-color: transparent; + } +} +.highlight { + animation: yellow-fade 1s ease-in 1; +} +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.number-pad { + border-collapse: collapse; + cursor: pointer; + display: table; + margin: auto; +} +.num-row { + display: table-row; +} +.num-col { + display: table-cell; + border: 1px solid #d1d8dd; +} +.num-col > div { + width: 50px; + height: 50px; + text-align: center; + line-height: 50px; +} diff --git a/erpnext/public/less/pos.less b/erpnext/public/less/pos.less index 9358f0a24c3..de165144f1c 100644 --- a/erpnext/public/less/pos.less +++ b/erpnext/public/less/pos.less @@ -35,6 +35,7 @@ } .cart-wrapper { + margin-bottom: 10px; .list-item__content:not(:first-child) { justify-content: flex-end; } @@ -43,6 +44,11 @@ .cart-items { height: 200px; overflow: auto; + + input { + height: 22px; + font-size: @text-medium; + } } .fields { @@ -84,4 +90,42 @@ left: 50%; transform: translate(-50%, -50%); } +} + +@keyframes yellow-fade { + 0% {background-color: @light-yellow;} + 100% {background-color: transparent;} +} + +.highlight { + animation: yellow-fade 1s ease-in 1; +} + +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +// number pad + +.number-pad { + border-collapse: collapse; + cursor: pointer; + display: table; + margin: auto; +} +.num-row { + display: table-row; +} +.num-col { + display: table-cell; + border: 1px solid @border-color; + + & > div { + width: 50px; + height: 50px; + text-align: center; + line-height: 50px; + } } \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index c28a5bd8c6f..19eb70ee81a 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -1,3 +1,5 @@ +/* global Clusterize */ + frappe.pages['point-of-sale'].on_page_load = function(wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, @@ -5,11 +7,11 @@ frappe.pages['point-of-sale'].on_page_load = function(wrapper) { single_column: true }); - wrapper.pos = new erpnext.PointOfSale(wrapper); - cur_pos = wrapper.pos; + wrapper.pos = new PointOfSale(wrapper); + window.cur_pos = wrapper.pos; } -erpnext.PointOfSale = class PointOfSale { +class PointOfSale { constructor(wrapper) { this.wrapper = $(wrapper).find('.layout-main-section'); this.page = wrapper.page; @@ -20,24 +22,25 @@ erpnext.PointOfSale = class PointOfSale { ]; frappe.require(assets, () => { - this.prepare().then(() => { - this.make(); - this.bind_events(); - }); + this.make(); }); } - prepare() { - this.set_online_status(); - this.prepare_menu(); - this.make_sales_invoice_frm() - return this.get_pos_profile(); - } - make() { - this.make_dom(); - this.make_cart(); - this.make_items(); + return frappe.run_serially([ + () => { + this.prepare_dom(); + this.prepare_menu(); + this.set_online_status(); + }, + () => this.make_sales_invoice_frm(), + () => this.setup_pos_profile(), + () => { + this.make_cart(); + this.make_items(); + this.bind_events(); + } + ]); } set_online_status() { @@ -54,7 +57,7 @@ erpnext.PointOfSale = class PointOfSale { }); } - make_dom() { + prepare_dom() { this.wrapper.append(`
@@ -68,29 +71,64 @@ erpnext.PointOfSale = class PointOfSale { } make_cart() { - this.cart = new erpnext.POSCart(this.wrapper.find('.cart-container')); - } - - make_items() { - this.items = new erpnext.POSItems({ - wrapper: this.wrapper.find('.item-container'), - pos_profile: this.pos_profile, + this.cart = new POSCart({ + wrapper: this.wrapper.find('.cart-container'), events: { - item_click: (item_code) => this.add_item_to_cart(item_code) + customer_change: (customer) => this.cur_frm.set_value('customer', customer), + increase_qty: (item_code) => { + this.add_item_to_cart(item_code); + }, + decrease_qty: (item_code) => { + this.add_item_to_cart(item_code, -1); + } } }); } - add_item_to_cart(item_code) { - const item = this.items.get(item_code); - this.cart.add_item(item); + make_items() { + this.items = new POSItems({ + wrapper: this.wrapper.find('.item-container'), + pos_profile: this.pos_profile, + events: { + item_click: (item_code) => { + if(!this.cur_frm.doc.customer) { + frappe.throw(__('Please select a customer')); + } + this.add_item_to_cart(item_code); + } + } + }); + } + + add_item_to_cart(item_code, qty = 1) { + + if(this.cart.exists(item_code)) { + // increase qty by 1 + this.cur_frm.doc.items.forEach((item) => { + if (item.item_code === item_code) { + frappe.model.set_value(item.doctype, item.name, 'qty', item.qty + qty); + // update cart + this.cart.add_item(item); + } + }); + return; + } + + // add to cur_frm + const item = this.cur_frm.add_child('items', { item_code: item_code }); + this.cur_frm.script_manager + .trigger('item_code', item.doctype, item.name) + .then(() => { + // update cart + this.cart.add_item(item); + }); } bind_events() { } - get_pos_profile() { + setup_pos_profile() { return frappe.call({ method: 'erpnext.stock.get_item_details.get_pos_profile', args: { @@ -104,13 +142,14 @@ erpnext.PointOfSale = class PointOfSale { make_sales_invoice_frm() { const dt = 'Sales Invoice'; return new Promise(resolve => { - frappe.model.with_doctype(dt, function() { + frappe.model.with_doctype(dt, () => { const page = $('
'); const frm = new _f.Frm(dt, page, false); const name = frappe.model.make_new_doc_and_get_name(dt, true); frm.refresh(name); frm.doc.items = []; - resolve(frm); + this.cur_frm = frm; + resolve(); }); }); } @@ -141,16 +180,18 @@ erpnext.PointOfSale = class PointOfSale { } } -erpnext.POSCart = class POSCart { - constructor(wrapper) { +class POSCart { + constructor({wrapper, events}) { this.wrapper = wrapper; - this.items = {}; + this.events = events; this.make(); + this.bind_events(); } make() { this.make_dom(); this.make_customer_field(); + this.make_numpad(); } make_dom() { @@ -172,7 +213,10 @@ erpnext.POSCart = class POSCart {
+
+
`); + this.$cart_items = this.wrapper.find('.cart-items'); } make_customer_field() { @@ -181,8 +225,9 @@ erpnext.POSCart = class POSCart { fieldtype: 'Link', label: 'Customer', options: 'Customer', + reqd: 1, onchange: (e) => { - cur_frm.set_value('customer', this.customer_field.value); + this.events.customer_change.apply(null, [this.customer_field.get_value()]); } }, parent: this.wrapper.find('.customer-field'), @@ -190,96 +235,113 @@ erpnext.POSCart = class POSCart { }); } - add_item(item) { - const { item_code } = item; - const _item = this.items[item_code]; - - if (_item) { - // exists, increase quantity - _item.quantity += 1; - this.update_quantity(_item); - } else { - // add it to this.items - item['qty'] = 1; - this.child = cur_frm.add_child('items', item) - cur_frm.script_manager.trigger("item_code", this.child.doctype, this.child.name); - - const _item = { - doc: item, - quantity: 1, - discount: 2, - rate: 2 + make_numpad() { + this.numpad = new NumberPad({ + wrapper: this.wrapper.find('.number-pad-container'), + onclick: (btn_value) => { + // on click + console.log(btn_value); } - Object.assign(this.items, { - [item_code]: _item - }); - this.add_item_to_cart(_item); - } + }); } - add_item_to_cart(item) { + add_item(item) { this.wrapper.find('.cart-items .empty-state').hide(); - const $item = $(this.get_item_html(item)) - $item.appendTo(this.wrapper.find('.cart-items')); - // $item.addClass('added'); - // this.wrapper.find('.cart-items').append(this.get_item_html(item)) - } - update_quantity(item) { - this.wrapper.find(`.list-item[data-item-name="${item.doc.item_code}"] .quantity`) - .text(item.quantity); - - $.each(cur_frm.doc["items"] || [], function(i, d) { - if (d.item_code == item.doc.item_code) { - frappe.model.set_value(d.doctype, d.name, "qty", d.qty + 1); - } - }); - } - - remove_item(item_code) { - delete this.items[item_code]; - - // this.refresh(); - } - - refresh() { - const item_codes = Object.keys(this.items); - const html = item_codes - .map(item_code => this.get_item_html(item_code)) - .join(""); - this.wrapper.find('.cart-items').html(html); - } - - get_item_html(_item) { - - let item; - if (typeof _item === "object") { - item = _item; - } - else if (typeof _item === "string") { - item = this.items[_item]; + if (this.exists(item.item_code)) { + // update quantity + this.update_item(item); + } else { + // add to cart + const $item = $(this.get_item_html(item)); + $item.appendTo(this.$cart_items); } + this.highlight_item(item.item_code); + this.scroll_to_item(item.item_code); + } + update_item(item) { + const $item = this.$cart_items.find(`[data-item-code="${item.item_code}"]`); + if(item.qty > 0) { + $item.find('.quantity input').val(item.qty); + $item.find('.discount').text(item.discount_percentage); + $item.find('.rate').text(item.rate); + } else { + $item.remove(); + } + } + + exists(item_code) { + let $item = this.$cart_items.find(`[data-item-code="${item_code}"]`); + return $item.length > 0; + } + + highlight_item(item_code) { + const $item = this.$cart_items.find(`[data-item-code="${item_code}"]`); + $item.addClass('highlight'); + setTimeout(() => $item.removeClass('highlight'), 1000); + } + + scroll_to_item(item_code) { + const $item = this.$cart_items.find(`[data-item-code="${item_code}"]`); + const scrollTop = $item.offset().top - this.$cart_items.offset().top + this.$cart_items.scrollTop(); + this.$cart_items.animate({ scrollTop }); + } + + get_item_html(item) { return ` -
+
- ${item.doc.item_name} + ${item.item_name}
- ${item.quantity} + ${get_quantity_html(item.qty)}
- ${item.discount} + ${item.discount_percentage}%
${item.rate}
`; + + function get_quantity_html(value) { + return ` +
+ + + + + + + + + +
+ `; + } + } + + bind_events() { + const events = this.events; + this.$cart_items.on('click', + '[data-action="increment"], [data-action="decrement"]', function() { + const $btn = $(this); + const $item = $btn.closest('.list-item[data-item-code]'); + const item_code = $item.attr('data-item-code'); + const action = $btn.attr('data-action'); + + if(action === 'increment') { + events.increase_qty(item_code); + } else if(action === 'decrement') { + events.decrease_qty(item_code); + } + }); } } -erpnext.POSItems = class POSItems { +class POSItems { constructor({wrapper, pos_profile, events}) { this.wrapper = wrapper; this.pos_profile = pos_profile; @@ -439,16 +501,10 @@ erpnext.POSItems = class POSItems {
- ${!item_image ? - ` + ${!item_image ? ` ${frappe.get_abbr(item_title)} - ` : - '' - } - ${item_image ? - `${item_title}` : - '' - } + ` : '' } + ${item_image ? `${item_title}` : '' }
${item_price} @@ -509,12 +565,12 @@ erpnext.POSItems = class POSItems { "`tabItem`.`end_of_life`", "`tabItem`.`total_projected_qty`" ], + filters: [['disabled', '=', '0']], order_by: "`tabItem`.`modified` desc", page_length: page_length, start: start } - }) - .then(r => { + }).then(r => { const data = r.message; const items = frappe.utils.dict(data.keys, data.values); @@ -528,4 +584,52 @@ erpnext.POSItems = class POSItems { }); }); } +} + +class NumberPad { + constructor({wrapper, onclick}) { + this.wrapper = wrapper; + this.onclick = onclick; + this.make_dom(); + this.bind_events(); + } + + make_dom() { + const button_array = [ + [1, 2, 3, 'Qty'], + [4, 5, 6, 'Disc'], + [7, 8, 9, 'Price'], + ['Del', 0, '.', 'Pay'] + ]; + + this.wrapper.html(` +
+ ${button_array.map(get_row).join("")} +
+ `); + + function get_row(row) { + return '
' + row.map(get_col).join("") + '
'; + } + + function get_col(col) { + return `
${col}
`; + } + } + + bind_events() { + // bind click event + const me = this; + this.wrapper.on('click', '.num-col', function() { + const $btn = $(this); + me.highlight_button($btn); + me.onclick.apply(null, [$btn.attr('data-value')]); + }); + } + + highlight_button($btn) { + // const $btn = this.wrapper.find(`[data-value="${value}"]`); + $btn.addClass('highlight'); + setTimeout(() => $btn.removeClass('highlight'), 1000); + } } \ No newline at end of file From f03a73466cd15942c4d9b291a5e5bd128a174c1d Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 18 Aug 2017 12:15:19 +0530 Subject: [PATCH 7/7] Added modal for multi mode payment, provision to add write off and change amount --- .../page/point_of_sale/point_of_sale.js | 183 +++++++++++++++++- 1 file changed, 179 insertions(+), 4 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.js b/erpnext/selling/page/point_of_sale/point_of_sale.js index 19eb70ee81a..f4cc355624f 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.js +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -106,9 +106,11 @@ class PointOfSale { // increase qty by 1 this.cur_frm.doc.items.forEach((item) => { if (item.item_code === item_code) { - frappe.model.set_value(item.doctype, item.name, 'qty', item.qty + qty); + frappe.model.set_value(item.doctype, item.name, 'qty', item.qty + qty) + .then(() => { + this.cart.add_item(item); + }) // update cart - this.cart.add_item(item); } }); return; @@ -149,6 +151,7 @@ class PointOfSale { frm.refresh(name); frm.doc.items = []; this.cur_frm = frm; + this.cur_frm.set_value('is_pos', 1) resolve(); }); }); @@ -240,11 +243,21 @@ class POSCart { wrapper: this.wrapper.find('.number-pad-container'), onclick: (btn_value) => { // on click + if (btn_value == 'Pay') { + this.make_payment() + } + console.log(btn_value); } }); } + make_payment() { + this.payment = new MakePayment({ + frm: cur_frm + }) + } + add_item(item) { this.wrapper.find('.cart-items .empty-state').hide(); @@ -268,6 +281,7 @@ class POSCart { $item.find('.rate').text(item.rate); } else { $item.remove(); + frappe.model.clear_doc(item.doctype, item.name) } } @@ -284,8 +298,8 @@ class POSCart { scroll_to_item(item_code) { const $item = this.$cart_items.find(`[data-item-code="${item_code}"]`); - const scrollTop = $item.offset().top - this.$cart_items.offset().top + this.$cart_items.scrollTop(); - this.$cart_items.animate({ scrollTop }); + // const scrollTop = $item.offset().top - this.$cart_items.offset().top + this.$cart_items.scrollTop(); + // this.$cart_items.animate({ scrollTop }); } get_item_html(item) { @@ -632,4 +646,165 @@ class NumberPad { $btn.addClass('highlight'); setTimeout(() => $btn.removeClass('highlight'), 1000); } +} + +class MakePayment { + constructor({frm}) { + this.frm = frm + this.make(); + this.set_primary_action(); + this.show_total_amount(); + // this.show_outstanding_amount() + } + + make() { + const me = this; + this.update_flag() + + this.dialog = new frappe.ui.Dialog({ + title: __('Payment'), + fields: this.get_fields(), + width:800 + }); + + this.dialog.show(); + this.$body = this.dialog.body; + + this.numpad = new NumberPad({ + wrapper: $(this.$body).find('[data-fieldname = "numpad"]'), + onclick: (btn_value) => { + // on click + } + }); + } + + set_primary_action() { + this.dialog.set_primary_action(__("Submit"), function() { + //save form + }) + } + + get_fields() { + const me = this; + const total_amount = [ + { + fieldtype: 'HTML', + fieldname: "total_amount", + }, + { + fieldtype: 'Section Break', + label: __("Mode of Payments") + }, + ] + + const mode_of_paymen_fields = this.frm.doc.payments.map(p => { + return { + fieldtype: 'Currency', + label: __(p.mode_of_payment), + options: me.frm.doc.currency, + fieldname: p.mode_of_payment, + default: p.amount, + onchange: (e) => { + const fieldname = $(e.target).attr('data-fieldname'); + const value = this.dialog.get_value(fieldname); + me.update_payment_value(fieldname, value) + } + } + }) + + const other_fields = [ + { + fieldtype: 'Column Break', + }, + { + fieldtype: 'HTML', + fieldname: 'numpad' + }, + { + fieldtype: 'Section Break', + }, + { + fieldtype: 'Currency', + label: __("Write off Amount"), + options: me.frm.doc.currency, + fieldname: "write_off_amount", + default: me.frm.doc.write_off_amount, + onchange: (e) => { + me.update_cur_frm_value('write_off_amount', () => { + frappe.flags.change_amount = false; + me.update_change_amount() + }) + } + }, + { + fieldtype: 'Column Break', + }, + { + fieldtype: 'Currency', + label: __("Change Amount"), + options: me.frm.doc.currency, + fieldname: "change_amount", + default: me.frm.doc.change_amount, + onchange: (e) => { + me.update_cur_frm_value('change_amount', () => { + frappe.flags.write_off_amount = false; + me.update_write_off_amount() + }) + } + }, + ] + + $.merge(total_amount, mode_of_paymen_fields) + return $.merge(total_amount, other_fields) + } + + update_flag() { + frappe.flags.write_off_amount = true; + frappe.flags.change_amount = true; + } + + update_cur_frm_value(fieldname, callback) { + if (frappe.flags[fieldname]) { + const value = this.dialog.get_value(fieldname); + this.frm.set_value(fieldname, value) + .then(() => { + callback() + }) + } + + frappe.flags[fieldname] = true; + } + + update_payment_value(fieldname, value) { + var me = this; + $.each(this.frm.doc.payments, function(i, data) { + if (__(data.mode_of_payment) == __(fieldname)) { + frappe.model.set_value('Sales Invoice Payment', data.name, 'amount', value) + .then(() => { + me.update_change_amount(); + me.update_write_off_amount(); + }) + } + }); + } + + update_change_amount() { + this.dialog.set_value("change_amount", this.frm.doc.change_amount) + } + + update_write_off_amount() { + this.dialog.set_value("write_off_amount", this.frm.doc.write_off_amount) + } + + show_total_amount() { + const grand_total = format_currency(this.frm.doc.grand_total, this.frm.doc.currency) + const template = ` +

+ ${ __("Total Amount") }: + ${__(grand_total)} +

+ ` + this.total_amount_section = $(this.$body).find("[data-fieldname = 'total_amount']") + this.total_amount_section.append(template) + } } \ No newline at end of file