From 6e7db034f2eb0a0e18e1071fd2bfdb6fab6982f5 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 10 Aug 2017 11:22:03 +0530 Subject: [PATCH] [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 d69a306670..599372411a 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 0000000000..d44b17caaf --- /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 0000000000..c94f8b59f5 --- /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 0000000000..e69de29bb2 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 0000000000..c9ef30e7eb --- /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 0000000000..1e348c09af --- /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