[wip] New POS UI
diff --git a/erpnext/accounts/page/pos/pos.js b/erpnext/accounts/page/pos/pos.js
index d69a306..5993724 100644
--- a/erpnext/accounts/page/pos/pos.js
+++ b/erpnext/accounts/page/pos/pos.js
@@ -8,7 +8,8 @@
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 0000000..d44b17c
--- /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 0000000..c94f8b5
--- /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 0000000..e69de29
--- /dev/null
+++ b/erpnext/selling/page/point_of_sale/__init__.py
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 0000000..c9ef30e
--- /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(`
+ <div class="pos">
+ <section class="customer-container">
+ <div class="customer-field">
+ </div>
+ <div class="cart-wrapper">
+ </div>
+ </section>
+ <section class="item-container">
+
+ </section>
+ </div>
+ `);
+ }
+
+ 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 = $('<div>');
+ 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(`
+ <div class="list-item-table">
+ <div class="list-item list-item--head">
+ <div class="list-item__content list-item__content--flex-2 text-muted">${__('Item Name')}</div>
+ <div class="list-item__content text-muted text-right">${__('Quantity')}</div>
+ <div class="list-item__content text-muted text-right">${__('Discount')}</div>
+ <div class="list-item__content text-muted text-right">${__('Rate')}</div>
+ </div>
+ <div class="cart-items">
+ <div class="empty-state">
+ <span>No Items added to cart</span>
+ </div>
+ </div>
+ </div>
+ `);
+ }
+
+ 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 `
+ <div class="list-item" data-item-name="${item.doc.item_code}">
+ <div class="item-name list-item__content list-item__content--flex-2 ellipsis">
+ ${item.doc.item_name}
+ </div>
+ <div class="quantity list-item__content text-right">
+ ${item.quantity}
+ </div>
+ <div class="discount list-item__content text-right">
+ ${item.discount}
+ </div>
+ <div class="rate list-item__content text-right">
+ ${item.rate}
+ </div>
+ </div>
+ `;
+ }
+}
+
+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(`
+ <div class="fields">
+ <div class="search-field">
+ </div>
+ <div class="item-group-field">
+ </div>
+ </div>
+ <div class="items-wrapper">
+ </div>
+ `);
+
+ this.items_wrapper = this.wrapper.find('.items-wrapper');
+ this.items_wrapper.append(`
+ <div class="list-item-table pos-items-wrapper">
+ <div class="pos-items image-view-container">
+ </div>
+ </div>
+ `);
+ }
+
+ 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 = '<div style="display: flex; border-bottom: 1px solid #ebeff2">';
+ 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 += '</div>';
+ 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 = `
+ <div class="pos-item-wrapper image-view-item" data-item-code="${item_code}">
+ <div class="image-view-header">
+ <div>
+ <a class="grey list-id" data-name="${item_code}" title="${item_title}">
+ ${item_title}
+ </a>
+ <p class="text-muted small">(${__(item_stock)})</p>
+ </div>
+ </div>
+ <div class="image-view-body">
+ <a data-item-code="${item_code}"
+ title="${item_title}"
+ >
+ <div class="image-field"
+ style="${!item_image ? 'background-color: #fafbfc;' : ''} border: 0px;"
+ >
+ ${!item_image ?
+ `<span class="placeholder-text">
+ ${frappe.get_abbr(item_title)}
+ </span>` :
+ ''
+ }
+ ${item_image ?
+ `<img src="${item_image}" alt="${item_title}">` :
+ ''
+ }
+ </div>
+ <span class="price-info">
+ ${item_price}
+ </span>
+ </a>
+ </div>
+ </div>
+ `;
+
+ // const template = `
+
+ // <div class="pos-item-wrapper" data-item-code="${item_code}">
+ // <div class="pos-item-head">
+ // <span class="bold">${item_name}</span>
+ // <span class="text-muted">Stock: ${item_stock}</span>
+ // </div>
+ // <div class="pos-item-body">
+ // <div class="pos-item-image text-center"
+ // style="${!item_image ?
+ // 'background-color: #fafbfc;' : ''
+ // } border: 0px;">
+ // ${item_image ?
+ // `<img src="${item_image}" alt="${item_title}">` :
+ // `<span class="placeholder-text">
+ // ${frappe.get_abbr(item_title)}
+ // </span>`
+ // }
+ // </div>
+ // </div>
+ // </div>
+
+ // `;
+
+ 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 0000000..1e348c0
--- /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