diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.js b/erpnext/accounts/doctype/pos_profile/pos_profile.js index 03d84e9ad6f..97bbc1227f1 100755 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.js +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.js @@ -8,10 +8,6 @@ frappe.ui.form.on("POS Profile", "onload", function(frm) { return { filters: { selling: 1 } }; }); - frm.set_query("print_format", function() { - return { filters: { doc_type: "Sales Invoice", print_format_type: "Js"} }; - }); - erpnext.queries.setup_queries(frm, "Warehouse", function() { return erpnext.queries.warehouse(frm.doc); }); @@ -27,6 +23,27 @@ frappe.ui.form.on("POS Profile", "onload", function(frm) { }); frappe.ui.form.on('POS Profile', { + setup: function(frm) { + frm.set_query("online_print_format", function() { + return { + filters: [ + ['Print Format', 'doc_type', '=', 'Sales Invoice'], + ['Print Format', 'print_format_type', '!=', 'Js'], + ] + }; + }); + + frm.set_query("print_format", function() { + return { filters: { doc_type: "Sales Invoice", print_format_type: "Js"} }; + }); + + frappe.db.get_value('POS Settings', {name: 'POS Settings'}, 'is_online', (r) => { + is_online = r && cint(r.is_online) + frm.toggle_display('offline_pos_section', !is_online); + frm.toggle_display('print_format_for_online', is_online); + }); + }, + refresh: function(frm) { if(frm.doc.company) { frm.trigger("toggle_display_account_head"); diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 6991da2888a..187454ef332 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -631,8 +631,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "default": "Point of Sale", - "fieldname": "print_format", + "fieldname": "print_format_for_online", "fieldtype": "Link", "hidden": 0, "ignore_user_permissions": 0, @@ -641,7 +640,7 @@ "in_global_search": 0, "in_list_view": 0, "in_standard_filter": 0, - "label": "Print Format", + "label": "Print Format for Online", "length": 0, "no_copy": 0, "options": "Print Format", @@ -822,7 +821,7 @@ "columns": 0, "fieldname": "apply_discount", "fieldtype": "Check", - "hidden": 0, + "hidden": 1, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_filter": 0, @@ -836,7 +835,7 @@ "precision": "", "print_hide": 0, "print_hide_if_no_value": 0, - "read_only": 0, + "read_only": 1, "remember_last_selected_value": 0, "report_hide": 0, "reqd": 0, @@ -851,7 +850,7 @@ "collapsible": 0, "columns": 0, "default": "Grand Total", - "depends_on": "apply_discount", + "depends_on": "", "fieldname": "apply_discount_on", "fieldtype": "Select", "hidden": 0, @@ -883,7 +882,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "fieldname": "customer_details", + "fieldname": "offline_pos_section", "fieldtype": "Section Break", "hidden": 0, "ignore_user_permissions": 0, @@ -892,7 +891,7 @@ "in_global_search": 0, "in_list_view": 0, "in_standard_filter": 0, - "label": "New Customer Details", + "label": "Offline POS Section", "length": 0, "no_copy": 0, "permlevel": 0, @@ -969,6 +968,38 @@ "set_only_once": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "Point of Sale", + "fieldname": "print_format", + "fieldtype": "Link", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Print Format", + "length": 0, + "no_copy": 0, + "options": "Print Format", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, { "allow_bulk_edit": 0, "allow_on_submit": 0, @@ -1291,7 +1322,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2017-07-28 03:40:03.253088", + "modified": "2017-09-01 15:55:14.890452", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/pos_settings/__init__.py b/erpnext/accounts/doctype/pos_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js new file mode 100644 index 00000000000..1a146185139 --- /dev/null +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('POS Settings', { + refresh: function() { + + } +}); diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.json b/erpnext/accounts/doctype/pos_settings/pos_settings.json new file mode 100644 index 00000000000..a04558da26c --- /dev/null +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.json @@ -0,0 +1,94 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2017-08-28 16:46:41.732676", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "1", + "fieldname": "is_online", + "fieldtype": "Check", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Online", + "length": 0, + "no_copy": 0, + "options": "", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 0, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 1, + "istable": 0, + "max_attachments": 0, + "modified": "2017-08-30 18:34:58.960276", + "modified_by": "Administrator", + "module": "Accounts", + "name": "POS Settings", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 0, + "delete": 0, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "track_seen": 0 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.py b/erpnext/accounts/doctype/pos_settings/pos_settings.py new file mode 100644 index 00000000000..736d36eea96 --- /dev/null +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from frappe.model.document import Document + +class POSSettings(Document): + pass \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.js b/erpnext/accounts/doctype/pos_settings/test_pos_settings.js similarity index 67% rename from erpnext/selling/doctype/sales_order/test_sales_order.js rename to erpnext/accounts/doctype/pos_settings/test_pos_settings.js index 57ed19b6965..639c94ed10d 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.js +++ b/erpnext/accounts/doctype/pos_settings/test_pos_settings.js @@ -2,15 +2,15 @@ // rename this file from _test_[name] to test_[name] to activate // and remove above this line -QUnit.test("test: Sales Order", function (assert) { +QUnit.test("test: POS Settings", function (assert) { let done = assert.async(); // number of asserts assert.expect(1); - frappe.run_serially('Sales Order', [ - // insert a new Sales Order - () => frappe.tests.make([ + frappe.run_serially([ + // insert a new POS Settings + () => frappe.tests.make('POS Settings', [ // values to be set {key: 'value'} ]), diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index f16e4b43271..3f8a1fb6c65 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -313,7 +313,7 @@ class SalesInvoice(SellingController): for fieldname in ('territory', 'naming_series', 'currency', 'taxes_and_charges', 'letter_head', 'tc_name', 'selling_price_list', 'company', 'select_print_heading', 'cash_bank_account', - 'write_off_account', 'write_off_cost_center'): + 'write_off_account', 'write_off_cost_center', 'apply_discount_on'): if (not for_validate) or (for_validate and not self.get(fieldname)): self.set(fieldname, pos.get(fieldname)) diff --git a/erpnext/accounts/page/pos/pos.js b/erpnext/accounts/page/pos/pos.js index 33b41e9ee44..2f425248a16 100644 --- a/erpnext/accounts/page/pos/pos.js +++ b/erpnext/accounts/page/pos/pos.js @@ -8,7 +8,16 @@ frappe.pages['pos'].on_page_load = function (wrapper) { single_column: true }); - wrapper.pos = new erpnext.pos.PointOfSale(wrapper) + frappe.db.get_value('POS Settings', {name: 'POS Settings'}, 'is_online', (r) => { + if (r && r.is_online && !cint(r.is_online)) { + // offline + wrapper.pos = new erpnext.pos.PointOfSale(wrapper); + cur_pos = wrapper.pos; + } else { + // online + frappe.set_route('point-of-sale'); + } + }); } frappe.pages['pos'].refresh = function (wrapper) { diff --git a/erpnext/accounts/page/pos/test_pos.js b/erpnext/accounts/page/pos/test_pos.js index bc5edc9f2a4..8913a9e1cc0 100644 --- a/erpnext/accounts/page/pos/test_pos.js +++ b/erpnext/accounts/page/pos/test_pos.js @@ -1,16 +1,15 @@ -QUnit.test("test:POS Profile", function(assert) { - assert.expect(1); +QUnit.test("test:Sales Invoice", function(assert) { + assert.expect(3); let done = assert.async(); frappe.run_serially([ () => { return frappe.tests.make("POS Profile", [ {naming_series: "SINV"}, - {company: "Test Company"}, {country: "India"}, {currency: "INR"}, - {write_off_account: "Write Off - TC"}, - {write_off_cost_center: "Main - TC"}, + {write_off_account: "Write Off - FT"}, + {write_off_cost_center: "Main - FT"}, {payments: [ [ {"default": 1}, @@ -24,19 +23,10 @@ QUnit.test("test:POS Profile", function(assert) { () => { assert.equal(cur_frm.doc.payments[0].default, 1, "Default mode of payment tested"); }, - () => done() - ]); -}); - -QUnit.test("test:Sales Invoice", function(assert) { - assert.expect(2); - let done = assert.async(); - - frappe.run_serially([ + () => frappe.timeout(1), () => { return frappe.tests.make("Sales Invoice", [ {customer: "Test Customer 2"}, - {company: "Test Company"}, {is_pos: 1}, {posting_date: frappe.datetime.get_today()}, {due_date: frappe.datetime.get_today()}, @@ -44,7 +34,7 @@ QUnit.test("test:Sales Invoice", function(assert) { [ {"item_code": "Test Product 1"}, {"qty": 5}, - {"warehouse":'Stores - TC'} + {"warehouse":'Stores - FT'} ]] } ]); diff --git a/erpnext/accounts/print_format/point_of_sale/point_of_sale.json b/erpnext/accounts/print_format/point_of_sale/point_of_sale.json index 28c853cc48d..4e69cad06bc 100644 --- a/erpnext/accounts/print_format/point_of_sale/point_of_sale.json +++ b/erpnext/accounts/print_format/point_of_sale/point_of_sale.json @@ -10,7 +10,7 @@ "html": "\n\n

\n\t{{ company }}
\n\t{{ __(\"POS No : \") }} {{ offline_pos_name }}
\n

\n

\n\t{{ __(\"Customer\") }}: {{ customer }}
\n

\n\n

\n\t{{ __(\"Date\") }}: {{ dateutil.global_date_format(posting_date) }}
\n

\n\n
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t{% for item in items %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% endfor %}\n\t\n
{{ __(\"Item\") }}{{ __(\"Qty\") }}{{ __(\"Amount\") }}
\n\t\t\t\t{{ item.item_name }}\n\t\t\t{{ format_number(item.qty, null,precision(\"difference\")) }}
@ {{ format_currency(item.rate, currency) }}
{{ format_currency(item.amount, currency) }}
\n\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% for row in taxes %}\n\t\t{% if not row.included_in_print_rate %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% endif %}\n\t\t{% endfor %}\n\t\t{% if discount_amount %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% endif %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{{ __(\"Net Total\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(total, currency) }}\n\t\t\t
\n\t\t\t\t{{ row.description }}\n\t\t\t\n\t\t\t\t{{ format_currency(row.tax_amount, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Discount\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(discount_amount, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Grand Total\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(grand_total, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Paid Amount\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(paid_amount, currency) }}\n\t\t\t
\n\n\n
\n

{{ terms }}

\n

{{ __(\"Thank you, please visit again.\") }}

", "idx": 0, "line_breaks": 0, - "modified": "2017-05-19 14:36:04.740728", + "modified": "2017-09-01 14:27:04.871233", "modified_by": "Administrator", "module": "Accounts", "name": "Point of Sale", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c1d713eb637..39cc0eacc15 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -435,6 +435,7 @@ erpnext.patches.v8_5.remove_project_type_property_setter erpnext.patches.v8_7.add_more_gst_fields erpnext.patches.v8_7.fix_purchase_receipt_status erpnext.patches.v8_6.rename_bom_update_tool +erpnext.patches.v8_7.set_offline_in_pos_settings erpnext.patches.v8_9.add_setup_progress_actions erpnext.patches.v8_9.rename_company_sales_target_field erpnext.patches.v8_8.set_bom_rate_as_per_uom diff --git a/erpnext/patches/v8_7/set_offline_in_pos_settings.py b/erpnext/patches/v8_7/set_offline_in_pos_settings.py new file mode 100644 index 00000000000..64a3a7c8065 --- /dev/null +++ b/erpnext/patches/v8_7/set_offline_in_pos_settings.py @@ -0,0 +1,12 @@ +# Copyright (c) 2017, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc('accounts', 'doctype', 'pos_settings') + + doc = frappe.get_doc('POS Settings') + doc.is_online = 0 + doc.save() \ No newline at end of file diff --git a/erpnext/public/css/erpnext.css b/erpnext/public/css/erpnext.css index 0660b392086..13fdcf15010 100644 --- a/erpnext/public/css/erpnext.css +++ b/erpnext/public/css/erpnext.css @@ -308,16 +308,6 @@ body[data-route="pos"] .item-list .image-field .placeholder-text { body[data-route="pos"] .item-list .pos-item-wrapper { position: relative; } -body[data-route="pos"] .item-list .price-info { - position: absolute; - left: 0; - bottom: 0; - margin: 0 0 15px 15px; - background-color: rgba(141, 153, 166, 0.6); - padding: 5px 9px; - border-radius: 3px; - color: #fff; -} body[data-route="pos"] .pos-bill-toolbar { margin-top: 10px; } @@ -356,3 +346,13 @@ body[data-route="pos"] .btn-more { body[data-route="pos"] .collapse-btn { cursor: pointer; } +.price-info { + position: absolute; + left: 0; + bottom: 0; + margin: 0 0 15px 15px; + background-color: rgba(141, 153, 166, 0.6); + padding: 5px 9px; + border-radius: 3px; + color: #fff; +} diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css new file mode 100644 index 00000000000..f66abc80816 --- /dev/null +++ b/erpnext/public/css/pos.css @@ -0,0 +1,174 @@ +[data-route="point-of-sale"] .layout-main-section-wrapper { + margin-bottom: 0; +} +[data-route="point-of-sale"] .pos-items-wrapper { + max-height: calc(100vh - 210px); +} +.pos { + padding: 15px; +} +.list-item { + min-height: 40px; + height: auto; +} +.cart-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; +} +.search-field { + width: 60%; +} +.search-field input::placeholder { + font-size: 12px; +} +.item-group-field { + width: 40%; + margin-left: 15px; +} +.cart-wrapper { + margin-bottom: 10px; +} +.cart-wrapper .list-item__content:not(:first-child) { + justify-content: flex-end; +} +.cart-wrapper .list-item--head .list-item__content:nth-child(2) { + flex: 1.5; +} +.cart-items { + height: 150px; + overflow: auto; +} +.cart-items .list-item.current-item { + background-color: #fffce7; +} +.cart-items .list-item.current-item.qty input { + border: 1px solid #5E64FF; + font-weight: bold; +} +.cart-items .list-item.current-item.disc .discount { + font-weight: bold; +} +.cart-items .list-item.current-item.rate .rate { + font-weight: bold; +} +.cart-items .list-item .quantity { + flex: 1.5; +} +.cart-items input { + text-align: right; + height: 22px; + font-size: 12px; +} +.fields { + display: flex; +} +.pos-items-wrapper { + max-height: 480px; + overflow-y: auto; +} +.pos-items { + overflow: hidden; +} +.pos-item-wrapper { + display: flex; + flex-direction: column; + position: relative; + width: 25%; +} +.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%); +} +@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; +} +.num-col.active { + background-color: #fffce7; +} +.num-col.brand-primary { + background-color: #5E64FF; + color: #ffffff; +} +.discount-amount .discount-inputs { + display: flex; + flex-direction: column; + padding: 15px 0; +} +.discount-amount input:first-child { + margin-bottom: 10px; +} +.taxes-and-totals { + border-top: 1px solid #d1d8dd; +} +.taxes-and-totals .taxes { + display: flex; + flex-direction: column; + padding: 15px 0; + align-items: flex-end; +} +.taxes-and-totals .taxes > div:first-child { + margin-bottom: 10px; +} +.grand-total { + border-top: 1px solid #d1d8dd; +} +.grand-total .list-item { + height: 60px; +} +.grand-total .grand-total-value { + font-size: 24px; +} diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 91ae634c5e4..2d11d6bd44d 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -4,6 +4,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ setup: function() { this._super(); + frappe.flags.hide_serial_batch_dialog = false; frappe.ui.form.on(this.frm.doctype + " Item", "rate", function(frm, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); var has_margin_field = frappe.meta.has_field(cdt, 'margin_type'); @@ -314,12 +315,15 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ if(!r.exc) { me.frm.script_manager.trigger("price_list_rate", cdt, cdn); me.toggle_conversion_factor(item); - if(show_batch_dialog) { + if(show_batch_dialog && !frappe.flags.hide_serial_batch_dialog) { var d = locals[cdt][cdn]; $.each(r.message, function(k, v) { if(!d[k]) d[k] = v; }); - erpnext.show_serial_batch_selector(me.frm, d); + + erpnext.show_serial_batch_selector(me.frm, d, (item) => { + me.frm.script_manager.trigger('qty', item.doctype, item.name); + }); } } } @@ -1153,7 +1157,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ } }); -erpnext.show_serial_batch_selector = function(frm, d) { +erpnext.show_serial_batch_selector = function(frm, d, callback, show_dialog) { frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", function() { new erpnext.SerialNoBatchSelector({ frm: frm, @@ -1162,6 +1166,7 @@ erpnext.show_serial_batch_selector = function(frm, d) { type: "Warehouse", name: d.warehouse }, - }); + callback: callback + }, show_dialog); }); } diff --git a/erpnext/public/js/pos/clusterize.js b/erpnext/public/js/pos/clusterize.js new file mode 100644 index 00000000000..075c9ca4ae6 --- /dev/null +++ b/erpnext/public/js/pos/clusterize.js @@ -0,0 +1,330 @@ +/* eslint-disable */ +/*! 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/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 08630e59984..3e2414e665b 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -1,15 +1,16 @@ erpnext.SerialNoBatchSelector = Class.extend({ - init: function(opts) { + init: function(opts, show_dialog) { $.extend(this, opts); + this.show_dialog = show_dialog; // frm, item, warehouse_details, has_batch, oldest let d = this.item; // Don't show dialog if batch no or serial no already set - if(d && d.has_batch_no && !d.batch_no) { + if(d && d.has_batch_no && (!d.batch_no || this.show_dialog)) { this.has_batch = 1; this.setup(); - } else if(d && d.has_serial_no && !d.serial_no) { + } else if(d && d.has_serial_no && (!d.serial_no || this.show_dialog)) { this.has_batch = 0; this.setup(); } @@ -93,6 +94,11 @@ erpnext.SerialNoBatchSelector = Class.extend({ } }); + if(this.show_dialog) { + let d = this.item; + this.dialog.set_value('serial_no', d.serial_no); + } + this.dialog.show(); }, @@ -140,6 +146,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ this.map_row_values(this.item, this.values, 'serial_no', 'qty'); } refresh_field("items"); + this.callback && this.callback(this.item); }, map_row_values: function(row, values, number, qty_field, warehouse) { diff --git a/erpnext/public/less/erpnext.less b/erpnext/public/less/erpnext.less index 6c616c9e32a..de46c53df88 100644 --- a/erpnext/public/less/erpnext.less +++ b/erpnext/public/less/erpnext.less @@ -364,17 +364,6 @@ body[data-route="pos"] { .pos-item-wrapper { position: relative; } - - .price-info { - position: absolute; - left: 0; - bottom: 0; - margin: 0 0 15px 15px; - background-color: rgba(141, 153, 166, 0.6); - padding: 5px 9px; - border-radius: 3px; - color: #fff; - } } .pos-bill-toolbar { @@ -423,4 +412,15 @@ body[data-route="pos"] { .collapse-btn { cursor: pointer; } +} + +.price-info { + position: absolute; + left: 0; + bottom: 0; + margin: 0 0 15px 15px; + background-color: rgba(141, 153, 166, 0.6); + padding: 5px 9px; + border-radius: 3px; + color: #fff; } \ No newline at end of file diff --git a/erpnext/public/less/pos.less b/erpnext/public/less/pos.less new file mode 100644 index 00000000000..9653a826585 --- /dev/null +++ b/erpnext/public/less/pos.less @@ -0,0 +1,222 @@ +@import "../../../../frappe/frappe/public/less/variables.less"; + +[data-route="point-of-sale"] { + .layout-main-section-wrapper { + margin-bottom: 0; + } + + .pos-items-wrapper { + max-height: ~"calc(100vh - 210px)"; + } +} + +.pos { + // display: flex; + padding: 15px; +} + +.list-item { + min-height: 40px; + height: auto; +} + +.cart-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; +} + +.search-field { + width: 60%; + + input::placeholder { + font-size: @text-medium; + } +} + +.item-group-field { + width: 40%; + margin-left: 15px; +} + +.cart-wrapper { + margin-bottom: 10px; + .list-item__content:not(:first-child) { + justify-content: flex-end; + } + + .list-item--head .list-item__content:nth-child(2) { + flex: 1.5; + } +} + +.cart-items { + height: 150px; + overflow: auto; + + .list-item.current-item { + background-color: @light-yellow; + } + + .list-item.current-item.qty input { + border: 1px solid @brand-primary; + font-weight: bold; + } + + .list-item.current-item.disc .discount { + font-weight: bold; + } + + .list-item.current-item.rate .rate { + font-weight: bold; + } + + .list-item .quantity { + flex: 1.5; + } + + input { + text-align: right; + height: 22px; + font-size: @text-medium; + } +} + +.fields { + display: flex; +} + +.pos-items-wrapper { + max-height: 480px; + overflow-y: auto; +} + +.pos-items { + overflow: hidden; +} + +.pos-item-wrapper { + display: flex; + flex-direction: column; + position: relative; + width: 25%; +} + +.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%); + } +} + +@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; + } + + &.active { + background-color: @light-yellow; + } + + &.brand-primary { + background-color: @brand-primary; + color: #ffffff; + } +} + +// taxes, totals and discount area +.discount-amount { + .discount-inputs { + display: flex; + flex-direction: column; + padding: 15px 0; + } + + input:first-child { + margin-bottom: 10px; + } +} + +.taxes-and-totals { + border-top: 1px solid @border-color; + + .taxes { + display: flex; + flex-direction: column; + padding: 15px 0; + align-items: flex-end; + + & > div:first-child { + margin-bottom: 10px; + } + } +} + +.grand-total { + border-top: 1px solid @border-color; + + .list-item { + height: 60px; + } + + .grand-total-value { + font-size: 24px; + } +} \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/tests/test_sales_order.js b/erpnext/selling/doctype/sales_order/tests/test_sales_order.js index 3eceb89ca26..6568d5cad09 100644 --- a/erpnext/selling/doctype/sales_order/tests/test_sales_order.js +++ b/erpnext/selling/doctype/sales_order/tests/test_sales_order.js @@ -1,7 +1,7 @@ QUnit.module('Sales Order'); QUnit.test("test sales order", function(assert) { - assert.expect(8); + assert.expect(10); let done = assert.async(); frappe.run_serially([ () => { @@ -12,7 +12,7 @@ QUnit.test("test sales order", function(assert) { {'delivery_date': frappe.datetime.add_days(frappe.defaults.get_default("year_end_date"), 1)}, {'qty': 5}, {'item_code': 'Test Product 4'}, - {'uom': 'unit'}, + {'uom': 'Nos'}, {'margin_type': 'Percentage'}, {'discount_percentage': 10}, ] @@ -33,7 +33,7 @@ QUnit.test("test sales order", function(assert) { {additional_discount_percentage:10} ]); }, - () => cur_frm.save(), + () => frappe.timeout(1), () => { // get_item_details assert.ok(cur_frm.doc.items[0].item_name=='Test Product 4', "Item name correct"); @@ -42,15 +42,19 @@ QUnit.test("test sales order", function(assert) { // get tax account head details assert.ok(cur_frm.doc.taxes[0].account_head=='CGST - '+frappe.get_abbr(frappe.defaults.get_default('Company')), " Account Head abbr correct"); // calculate totals - assert.ok(cur_frm.doc.items[0].price_list_rate==1000, "Item 1 price_list_rate"); - assert.ok(cur_frm.doc.total== 4500, "total correct "); - assert.ok(cur_frm.doc.rounded_total== 4414.5, "rounded total correct "); - + assert.ok(cur_frm.doc.items[0].price_list_rate==90, "Item 1 price_list_rate"); + assert.ok(cur_frm.doc.total== 405, "total correct "); + assert.ok(cur_frm.doc.net_total== 364.5, "net total correct "); + assert.ok(cur_frm.doc.grand_total== 397.30, "grand total correct "); + assert.ok(cur_frm.doc.rounded_total== 397.30, "rounded total correct "); }, + () => cur_frm.save(), + () => frappe.timeout(1), () => cur_frm.print_doc(), () => frappe.timeout(1), () => { assert.ok($('.btn-print-print').is(':visible'), "Print Format Available"); + frappe.timeout(1); assert.ok($(".section-break+ .section-break .column-break:nth-child(1) .data-field:nth-child(1) .value").text().includes("Billing Street 1"), "Print Preview Works As Expected"); }, () => cur_frm.print_doc(), 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..9cd2a499126 --- /dev/null +++ b/erpnext/selling/page/point_of_sale/point_of_sale.js @@ -0,0 +1,1284 @@ +/* global Clusterize */ +frappe.provide('erpnext.pos'); + +frappe.pages['point-of-sale'].on_page_load = function(wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: 'Point of Sale', + single_column: true + }); + + frappe.db.get_value('POS Settings', {name: 'POS Settings'}, 'is_online', (r) => { + if (r && r.is_online && cint(r.is_online)) { + // online + wrapper.pos = new erpnext.pos.PointOfSale(wrapper); + window.cur_pos = wrapper.pos; + } else { + // offline + frappe.set_route('pos'); + } + }); +}; + +erpnext.pos.PointOfSale = class PointOfSale { + constructor(wrapper) { + this.wrapper = $(wrapper).find('.layout-main-section'); + this.page = wrapper.page; + + const assets = [ + 'assets/erpnext/js/pos/clusterize.js', + 'assets/erpnext/css/pos.css' + ]; + + frappe.require(assets, () => { + this.make(); + }); + } + + make() { + return frappe.run_serially([ + () => { + this.prepare_dom(); + this.prepare_menu(); + this.set_online_status(); + }, + () => this.setup_pos_profile(), + () => { + this.make_items(); + this.bind_events(); + }, + () => this.make_new_invoice(), + () => this.page.set_title(__('Point of Sale')) + ]); + } + + 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_dom() { + this.wrapper.append(` +
+
+ +
+
+ +
+
+ `); + } + + make_cart() { + this.cart = new POSCart({ + frm: this.frm, + wrapper: this.wrapper.find('.cart-container'), + events: { + on_customer_change: (customer) => this.frm.set_value('customer', customer), + on_field_change: (item_code, field, value) => { + this.update_item_in_cart(item_code, field, value); + }, + on_numpad: (value) => { + if (value == 'Pay') { + if (!this.payment) { + this.make_payment_modal(); + } + this.payment.open_modal(); + } + }, + on_select_change: () => { + this.cart.numpad.set_inactive(); + } + } + }); + } + + toggle_editing(flag) { + let disabled; + if (flag !== undefined) { + disabled = !flag; + } else { + disabled = this.frm.doc.docstatus == 1 ? true: false; + } + const pointer_events = disabled ? 'none' : 'inherit'; + + this.wrapper.find('input, button, select').prop("disabled", disabled); + this.wrapper.find('.number-pad-container').toggleClass("hide", disabled); + + this.wrapper.find('.cart-container').css('pointer-events', pointer_events); + this.wrapper.find('.item-container').css('pointer-events', pointer_events); + + this.page.clear_actions(); + } + + make_items() { + this.items = new POSItems({ + wrapper: this.wrapper.find('.item-container'), + pos_profile: this.pos_profile, + events: { + update_cart: (item, field, value) => { + if(!this.frm.doc.customer) { + frappe.throw(__('Please select a customer')); + } + this.update_item_in_cart(item, field, value); + this.cart && this.cart.unselect_all(); + } + } + }); + } + + update_item_in_cart(item_code, field='qty', value=1) { + if(this.cart.exists(item_code)) { + const item = this.frm.doc.items.find(i => i.item_code === item_code); + frappe.flags.hide_serial_batch_dialog = false; + + if (typeof value === 'string' && !in_list(['serial_no', 'batch_no'], field)) { + // value can be of type '+1' or '-1' + value = item[field] + flt(value); + } + + if(field === 'serial_no') { + value = item.serial_no + '\n'+ value; + } + + if(field === 'qty' && (item.serial_no || item.batch_no)) { + this.select_batch_and_serial_no(item); + } else { + this.update_item_in_frm(item, field, value) + .then(() => { + // update cart + this.update_cart_data(item); + }); + } + return; + } + + let args = { item_code: item_code }; + if (in_list(['serial_no', 'batch_no'], field)) { + args[field] = value; + } + + // add to cur_frm + const item = this.frm.add_child('items', args); + frappe.flags.hide_serial_batch_dialog = true; + this.frm.script_manager + .trigger('item_code', item.doctype, item.name) + .then(() => { + const show_dialog = item.has_serial_no || item.has_batch_no; + if (show_dialog && field == 'qty') { + // check has serial no/batch no and update cart + this.select_batch_and_serial_no(item); + } else { + // update cart + this.update_cart_data(item); + } + }); + } + + select_batch_and_serial_no(item) { + erpnext.show_serial_batch_selector(this.frm, item, () => { + this.update_item_in_frm(item) + .then(() => { + // update cart + this.update_cart_data(item); + }); + }, true); + } + + update_cart_data(item) { + this.cart.add_item(item); + this.cart.update_taxes_and_totals(); + this.cart.update_grand_total(); + } + + update_item_in_frm(item, field, value) { + if (field) { + frappe.model.set_value(item.doctype, item.name, field, value); + } + + return this.frm.script_manager + .trigger('qty', item.doctype, item.name) + .then(() => { + if (field === 'qty' && value === 0) { + frappe.model.clear_doc(item.doctype, item.name); + } + }); + } + + make_payment_modal() { + this.payment = new Payment({ + frm: this.frm, + events: { + submit_form: () => { + this.submit_sales_invoice(); + } + } + }); + } + + submit_sales_invoice() { + + frappe.confirm(__("Permanently Submit {0}?", [this.frm.doc.name]), () => { + frappe.call({ + method: 'erpnext.selling.page.point_of_sale.point_of_sale.submit_invoice', + freeze: true, + args: { + doc: this.frm.doc + } + }).then(r => { + if(r.message) { + this.frm.doc = r.message; + frappe.show_alert({ + indicator: 'green', + message: __(`Sales invoice ${r.message.name} created succesfully`) + }); + + this.toggle_editing(); + this.set_form_action(); + } + }); + }); + } + + bind_events() { + + } + + setup_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; + + if (!this.pos_profile) { + this.pos_profile = { + currency: frappe.defaults.get_default('currency'), + selling_price_list: frappe.defaults.get_default('selling_price_list') + }; + } + }); + } + + make_new_invoice() { + return frappe.run_serially([ + () => this.make_sales_invoice_frm(), + () => { + if (this.cart) { + this.cart.frm = this.frm; + this.cart.reset(); + } else { + this.make_cart(); + } + this.toggle_editing(true); + } + ]); + } + + make_sales_invoice_frm() { + const doctype = 'Sales Invoice'; + return new Promise(resolve => { + if (this.frm) { + this.frm = get_frm(this.frm); + resolve(); + } else { + frappe.model.with_doctype(doctype, () => { + this.frm = get_frm(); + resolve(); + }); + } + }); + + function get_frm(_frm) { + const page = $('
'); + const frm = _frm || new _f.Frm(doctype, page, false); + const name = frappe.model.make_new_doc_and_get_name(doctype, true); + frm.refresh(name); + frm.doc.items = []; + frm.set_value('is_pos', 1); + frm.meta.default_print_format = 'POS Invoice'; + return frm; + } + } + + prepare_menu() { + var me = this; + this.page.clear_menu(); + + // for mobile + // this.page.add_menu_item(__("Pay"), function () { + // + // }).addClass('visible-xs'); + + this.page.add_menu_item(__("Form View"), function () { + frappe.model.sync(me.frm.doc); + frappe.set_route("Form", me.frm.doc.doctype, me.frm.doc.name); + }); + + this.page.add_menu_item(__("POS Profile"), function () { + frappe.set_route('List', 'POS Profile'); + }); + + this.page.add_menu_item(__('POS Settings'), function() { + frappe.set_route('Form', 'POS Settings'); + }); + } + + set_form_action() { + if(this.frm.doc.docstatus !== 1) return; + + this.page.set_secondary_action(__("Print"), () => { + if (this.pos_profile && this.pos_profile.print_format_for_online) { + this.frm.meta.default_print_format = this.pos_profile.print_format_for_online; + } + this.frm.print_preview.printit(true); + }); + + this.page.set_primary_action(__("New"), () => { + this.make_new_invoice(); + }); + + this.page.add_menu_item(__("Email"), () => { + this.frm.email_doc(); + }); + } +}; + +class POSCart { + constructor({frm, wrapper, events}) { + this.frm = frm; + this.wrapper = wrapper; + this.events = events; + this.make(); + this.bind_events(); + } + + make() { + this.make_dom(); + this.make_customer_field(); + this.make_numpad(); + } + + make_dom() { + this.wrapper.append(` +
+
+
+
+
+
+
${__('Item Name')}
+
${__('Quantity')}
+
${__('Discount')}
+
${__('Rate')}
+
+
+
+ No Items added to cart +
+
+
+ ${this.get_taxes_and_totals()} +
+
+ ${this.get_discount_amount()} +
+
+ ${this.get_grand_total()} +
+
+
+
+
+
+ `); + this.$cart_items = this.wrapper.find('.cart-items'); + this.$empty_state = this.wrapper.find('.cart-items .empty-state'); + this.$taxes_and_totals = this.wrapper.find('.taxes-and-totals'); + this.$discount_amount = this.wrapper.find('.discount-amount'); + this.$grand_total = this.wrapper.find('.grand-total'); + + this.toggle_taxes_and_totals(false); + this.$grand_total.on('click', () => { + this.toggle_taxes_and_totals(); + }); + } + + reset() { + this.$cart_items.find('.list-item').remove(); + this.$empty_state.show(); + this.$taxes_and_totals.html(this.get_taxes_and_totals()); + this.numpad && this.numpad.reset_value(); + this.customer_field.set_value(""); + } + + get_grand_total() { + return ` +
+
${__('Grand Total')}
+
0.00
+
+ `; + } + + get_discount_amount() { + const get_currency_symbol = window.get_currency_symbol; + + return ` +
+
${__('Discount')}
+
+ + +
+
+ `; + } + + get_taxes_and_totals() { + return ` +
+
${__('Net Total')}
+
0.00
+
+
+
${__('Taxes')}
+
0.00
+
+ `; + } + + toggle_taxes_and_totals(flag) { + if (flag !== undefined) { + this.tax_area_is_shown = flag; + } else { + this.tax_area_is_shown = !this.tax_area_is_shown; + } + + this.$taxes_and_totals.toggle(this.tax_area_is_shown); + this.$discount_amount.toggle(this.tax_area_is_shown); + } + + update_taxes_and_totals() { + const currency = this.frm.doc.currency; + this.frm.refresh_field('taxes'); + + // Update totals + this.$taxes_and_totals.find('.net-total') + .html(format_currency(this.frm.doc.net_total, currency)); + + // Update taxes + const taxes_html = this.frm.doc.taxes.map(tax => { + return ` +
+ ${tax.description} + + ${format_currency(tax.tax_amount, currency)} + +
+ `; + }).join(""); + this.$taxes_and_totals.find('.taxes').html(taxes_html); + } + + update_grand_total() { + this.$grand_total.find('.grand-total-value').text( + format_currency(this.frm.doc.grand_total, this.frm.currency) + ); + } + + make_customer_field() { + this.customer_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Link', + label: 'Customer', + fieldname: 'customer', + options: 'Customer', + reqd: 1, + default: this.frm.doc.customer, + onchange: () => { + this.events.on_customer_change(this.customer_field.get_value()); + } + }, + parent: this.wrapper.find('.customer-field'), + render_input: true + }); + } + + make_numpad() { + this.numpad = new NumberPad({ + button_array: [ + [1, 2, 3, 'Qty'], + [4, 5, 6, 'Disc'], + [7, 8, 9, 'Rate'], + ['Del', 0, '.', 'Pay'] + ], + add_class: { + 'Pay': 'brand-primary' + }, + disable_highlight: ['Qty', 'Disc', 'Rate', 'Pay'], + reset_btns: ['Qty', 'Disc', 'Rate', 'Pay'], + del_btn: 'Del', + wrapper: this.wrapper.find('.number-pad-container'), + onclick: (btn_value) => { + // on click + if (!this.selected_item && btn_value !== 'Pay') { + frappe.show_alert({ + indicator: 'red', + message: __('Please select an item in the cart') + }); + return; + } + if (['Qty', 'Disc', 'Rate'].includes(btn_value)) { + this.set_input_active(btn_value); + } else if (btn_value !== 'Pay') { + if (!this.selected_item.active_field) { + frappe.show_alert({ + indicator: 'red', + message: __('Please select a field to edit from numpad') + }); + return; + } + + const item_code = this.selected_item.attr('data-item-code'); + const field = this.selected_item.active_field; + const value = this.numpad.get_value(); + + this.events.on_field_change(item_code, field, value); + } + + this.events.on_numpad(btn_value); + } + }); + } + + set_input_active(btn_value) { + this.selected_item.removeClass('qty disc rate'); + + this.numpad.set_active(btn_value); + if (btn_value === 'Qty') { + this.selected_item.addClass('qty'); + this.selected_item.active_field = 'qty'; + } else if (btn_value == 'Disc') { + this.selected_item.addClass('disc'); + this.selected_item.active_field = 'discount_percentage'; + } else if (btn_value == 'Rate') { + this.selected_item.addClass('rate'); + this.selected_item.active_field = 'rate'; + } + } + + add_item(item) { + this.$empty_state.hide(); + + 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) { + const indicator_class = item.actual_qty >= item.qty ? 'green' : 'red'; + const remove_class = indicator_class == 'green' ? 'red' : 'green'; + + $item.find('.quantity input').val(item.qty); + $item.find('.discount').text(item.discount_percentage + '%'); + $item.find('.rate').text(format_currency(item.rate, this.frm.doc.currency)); + $item.addClass(indicator_class); + $item.removeClass(remove_class); + } else { + $item.remove(); + } + } + + get_item_html(item) { + const rate = format_currency(item.rate, this.frm.doc.currency); + const indicator_class = item.actual_qty >= item.qty ? 'green' : 'red'; + return ` +
+
+ ${item.item_name} +
+
+ ${get_quantity_html(item.qty)} +
+
+ ${item.discount_percentage}% +
+
+ ${rate} +
+
+ `; + + function get_quantity_html(value) { + return ` +
+ + + + + + + + + +
+ `; + } + } + + 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}"]`); + if ($item.length === 0) return; + const scrollTop = $item.offset().top - this.$cart_items.offset().top + this.$cart_items.scrollTop(); + this.$cart_items.animate({ scrollTop }); + } + + bind_events() { + const me = this; + const events = this.events; + + // quantity change + 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.on_field_change(item_code, 'qty', '+1'); + } else if(action === 'decrement') { + events.on_field_change(item_code, 'qty', '-1'); + } + }); + + // this.$cart_items.on('focus', '.quantity input', function(e) { + // const $input = $(this); + // const $item = $input.closest('.list-item[data-item-code]'); + // me.set_selected_item($item); + // me.set_input_active('Qty'); + // e.preventDefault(); + // e.stopPropagation(); + // return false; + // }); + + this.$cart_items.on('change', '.quantity input', function() { + const $input = $(this); + const $item = $input.closest('.list-item[data-item-code]'); + const item_code = $item.attr('data-item-code'); + events.on_field_change(item_code, 'qty', flt($input.val())); + }); + + // current item + this.$cart_items.on('click', '.list-item', function() { + me.set_selected_item($(this)); + }); + + // disable current item + // $('body').on('click', function(e) { + // console.log(e); + // if($(e.target).is('.list-item')) { + // return; + // } + // me.$cart_items.find('.list-item').removeClass('current-item qty disc rate'); + // me.selected_item = null; + // }); + + this.wrapper.find('.additional_discount_percentage').on('change', (e) => { + frappe.model.set_value(this.frm.doctype, this.frm.docname, + 'additional_discount_percentage', e.target.value) + .then(() => { + let discount_wrapper = this.wrapper.find('.discount_amount'); + discount_wrapper.val(this.frm.doc.discount_amount); + discount_wrapper.trigger('change'); + }); + }); + + this.wrapper.find('.discount_amount').on('change', (e) => { + frappe.model.set_value(this.frm.doctype, this.frm.docname, + 'discount_amount', e.target.value); + this.frm.trigger('discount_amount') + .then(() => { + let discount_wrapper = this.wrapper.find('.additional_discount_percentage'); + discount_wrapper.val(this.frm.doc.additional_discount_percentage); + this.update_taxes_and_totals(); + this.update_grand_total(); + }); + }); + } + + set_selected_item($item) { + this.selected_item = $item; + this.$cart_items.find('.list-item').removeClass('current-item qty disc rate'); + this.selected_item.addClass('current-item'); + this.events.on_select_change(); + } + + unselect_all() { + this.$cart_items.find('.list-item').removeClass('current-item qty disc rate'); + this.selected_item = null; + this.events.on_select_change(); + } +} + +class POSItems { + constructor({wrapper, pos_profile, events}) { + this.wrapper = wrapper; + this.pos_profile = pos_profile; + this.items = {}; + this.events = events; + this.currency = this.pos_profile.currency; + + this.make_dom(); + this.make_fields(); + + this.init_clusterize(); + this.bind_events(); + + // bootstrap with 20 items + this.get_items() + .then(({ items }) => { + this.all_items = items; + this.items = items; + this.render_items(items); + }); + } + + make_dom() { + this.wrapper.html(` +
+
+
+
+
+
+
+
+ `); + + this.items_wrapper = this.wrapper.find('.items-wrapper'); + this.items_wrapper.append(` +
+
+
+
+ `); + } + + make_fields() { + // Search field + this.search_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Data', + 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) => { + clearTimeout(this.last_search); + this.last_search = setTimeout(() => { + const search_term = e.target.value; + this.filter_items({ search_term }); + }, 300); + }); + + this.item_group_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'Link', + label: 'Item Group', + options: 'Item Group', + default: 'All Item Groups', + onchange: () => { + const item_group = this.item_group_field.get_value(); + if (item_group) { + this.filter_items({ item_group: item_group }); + } + }, + }, + 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]; + + if(i == all_items.length - 1 && all_items.length % 4 !== 0) { + row_items.push(curr_row); + } + } + + this.clusterize.update(row_items); + } + + filter_items({ search_term='', item_group='All Item Groups' }={}) { + if (search_term) { + search_term = search_term.toLowerCase(); + + // memoize + this.search_index = this.search_index || {}; + if (this.search_index[search_term]) { + const items = this.search_index[search_term]; + this.render_items(items); + return; + } + } else if (item_group == "All Item Groups") { + return this.render_items(this.all_items); + } + + this.get_items({search_value: search_term, item_group }) + .then(({ items, serial_no, batch_no }) => { + if (search_term) { + this.search_index[search_term] = items; + } + + this.render_items(items); + if(serial_no) { + this.events.update_cart(items[0].item_code, + 'serial_no', serial_no); + this.search_field.set_value(''); + } + if(batch_no) { + this.events.update_cart(items[0].item_code, + 'batch_no', serial_no); + this.search_field.set_value(''); + } + }); + } + + bind_events() { + var me = this; + this.wrapper.on('click', '.pos-item-wrapper', function() { + const $item = $(this); + const item_code = $item.attr('data-item-code'); + me.events.update_cart(item_code, 'qty', '+1'); + }); + } + + get(item_code) { + return this.items[item_code]; + } + + get_all() { + return this.items; + } + + get_item_html(item) { + const price_list_rate = format_currency(item.price_list_rate, this.currency); + const { item_code, item_name, item_image} = item; + const item_title = item_name || item_code; + + const template = ` +
+ + +
+ `; + + return template; + } + + get_items({start = 0, page_length = 40, search_value='', item_group="All Item Groups"}={}) { + return new Promise(res => { + frappe.call({ + method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items", + args: { + start, + page_length, + 'price_list': this.pos_profile.selling_price_list, + item_group, + search_value + } + }).then(r => { + // const { items, serial_no, batch_no } = r.message; + + // this.serial_no = serial_no || ""; + res(r.message); + }); + }); + } +} + +class NumberPad { + constructor({ + wrapper, onclick, button_array, + add_class={}, disable_highlight=[], + reset_btns=[], del_btn='', + }) { + this.wrapper = wrapper; + this.onclick = onclick; + this.button_array = button_array; + this.add_class = add_class; + this.disable_highlight = disable_highlight; + this.reset_btns = reset_btns; + this.del_btn = del_btn; + this.make_dom(); + this.bind_events(); + this.value = ''; + } + + make_dom() { + if (!this.button_array) { + this.button_array = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ['', 0, ''] + ]; + } + + this.wrapper.html(` +
+ ${this.button_array.map(get_row).join("")} +
+ `); + + function get_row(row) { + return '
' + row.map(get_col).join("") + '
'; + } + + function get_col(col) { + return `
${col}
`; + } + + this.set_class(); + } + + set_class() { + for (const btn in this.add_class) { + const class_name = this.add_class[btn]; + this.get_btn(btn).addClass(class_name); + } + } + + bind_events() { + // bind click event + const me = this; + this.wrapper.on('click', '.num-col', function() { + const $btn = $(this); + const btn_value = $btn.attr('data-value'); + if (!me.disable_highlight.includes(btn_value)) { + me.highlight_button($btn); + } + if (me.reset_btns.includes(btn_value)) { + me.reset_value(); + } else { + if (btn_value === me.del_btn) { + me.value = me.value.substr(0, me.value.length - 1); + } else { + me.value += btn_value; + } + } + me.onclick(btn_value); + }); + } + + reset_value() { + this.value = ''; + } + + get_value() { + return flt(this.value); + } + + get_btn(btn_value) { + return this.wrapper.find(`.num-col[data-value="${btn_value}"]`); + } + + highlight_button($btn) { + $btn.addClass('highlight'); + setTimeout(() => $btn.removeClass('highlight'), 1000); + } + + set_active(btn_value) { + const $btn = this.get_btn(btn_value); + this.wrapper.find('.num-col').removeClass('active'); + $btn.addClass('active'); + } + + set_inactive() { + this.wrapper.find('.num-col').removeClass('active'); + } +} + +class Payment { + constructor({frm, events}) { + this.frm = frm; + this.events = events; + this.make(); + this.bind_events(); + this.set_primary_action(); + } + + open_modal() { + this.dialog.show(); + } + + make() { + this.set_flag(); + + let title = __('Total Amount {0}', + [format_currency(this.frm.doc.grand_total, this.frm.doc.currency)]); + + this.dialog = new frappe.ui.Dialog({ + title: title, + fields: this.get_fields(), + width: 800 + }); + + this.$body = this.dialog.body; + + this.numpad = new NumberPad({ + wrapper: $(this.$body).find('[data-fieldname="numpad"]'), + button_array: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ['Del', 0, '.'], + ], + onclick: () => { + if(this.fieldname) { + this.dialog.set_value(this.fieldname, this.numpad.get_value()); + } + } + }); + } + + bind_events() { + var me = this; + $(this.dialog.body).find('.input-with-feedback').focusin(function() { + me.numpad.reset_value(); + me.fieldname = $(this).prop('dataset').fieldname; + }); + } + + set_primary_action() { + var me = this; + + this.dialog.set_primary_action(__("Submit"), function() { + me.dialog.hide(); + me.events.submit_form(); + }); + } + + get_fields() { + const me = this; + + let 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: () => { + const value = this.dialog.get_value(this.fieldname); + me.update_payment_value(this.fieldname, value); + } + }; + }); + + fields = fields.concat([ + { + 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: () => { + 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: () => { + me.update_cur_frm_value('change_amount', () => { + frappe.flags.write_off_amount = false; + me.update_write_off_amount(); + }); + } + }, + { + fieldtype: 'Section Break', + }, + { + fieldtype: 'Currency', + label: __("Paid Amount"), + options: me.frm.doc.currency, + fieldname: "paid_amount", + default: me.frm.doc.paid_amount, + read_only: 1 + }, + { + fieldtype: 'Column Break', + }, + { + fieldtype: 'Currency', + label: __("Outstanding Amount"), + options: me.frm.doc.currency, + fieldname: "outstanding_amount", + default: me.frm.doc.outstanding_amount, + read_only: 1 + }, + ]); + + return fields; + } + + set_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); + this.show_paid_amount(); + } + + update_write_off_amount() { + this.dialog.set_value("write_off_amount", this.frm.doc.write_off_amount); + } + + show_paid_amount() { + this.dialog.set_value("paid_amount", this.frm.doc.paid_amount); + this.dialog.set_value("outstanding_amount", this.frm.doc.outstanding_amount); + } +} 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 diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py new file mode 100644 index 00000000000..8ed288b6e9d --- /dev/null +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -0,0 +1,71 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe, json + +@frappe.whitelist() +def get_items(start, page_length, price_list, item_group, search_value=""): + serial_no = "" + batch_no = "" + item_code = search_value + + if search_value: + # search serial no + serial_no_data = frappe.db.get_value('Serial No', search_value, ['name', 'item_code']) + if serial_no_data: + serial_no, item_code = serial_no_data + + if not serial_no: + batch_no_data = frappe.db.get_value('Batch', search_value, ['name', 'item']) + if batch_no_data: + batch_no, item_code = batch_no_data + + lft, rgt = frappe.db.get_value('Item Group', item_group, ['lft', 'rgt']) + # locate function is used to sort by closest match from the beginning of the value + res = frappe.db.sql("""select i.name as item_code, i.item_name, i.image as item_image, + item_det.price_list_rate, item_det.currency + from `tabItem` i LEFT JOIN + (select item_code, price_list_rate, currency from + `tabItem Price` where price_list=%(price_list)s) item_det + ON + (item_det.item_code=i.name or item_det.item_code=i.variant_of) + where + i.disabled = 0 and i.has_variants = 0 + and i.item_group in (select name from `tabItem Group` where lft >= {lft} and rgt <= {rgt}) + and (i.item_code like %(item_code)s + or i.item_name like %(item_code)s or i.barcode like %(item_code)s) + limit {start}, {page_length}""".format(start=start, page_length=page_length, lft=lft, rgt=rgt), + { + 'item_code': '%%%s%%'%(frappe.db.escape(item_code)), + 'price_list': price_list + } , as_dict=1) + + res = { + 'items': res + } + + if serial_no: + res.update({ + 'serial_no': serial_no + }) + + if batch_no: + res.update({ + 'batch_no': batch_no + }) + + return res + +@frappe.whitelist() +def submit_invoice(doc): + if isinstance(doc, basestring): + args = json.loads(doc) + + doc = frappe.new_doc('Sales Invoice') + doc.update(args) + doc.run_method("set_missing_values") + doc.run_method("calculate_taxes_and_totals") + doc.submit() + + return doc diff --git a/erpnext/selling/page/point_of_sale/tests/test_point_of_sale.js b/erpnext/selling/page/point_of_sale/tests/test_point_of_sale.js new file mode 100644 index 00000000000..c70d076c70a --- /dev/null +++ b/erpnext/selling/page/point_of_sale/tests/test_point_of_sale.js @@ -0,0 +1,38 @@ +QUnit.test("test:Point of Sales", function(assert) { + assert.expect(1); + let done = assert.async(); + + frappe.run_serially([ + () => frappe.set_route('point-of-sale'), + () => frappe.timeout(2), + () => frappe.set_control('customer', 'Test Customer 1'), + () => frappe.timeout(0.2), + () => cur_frm.set_value('customer', 'Test Customer 1'), + () => frappe.timeout(2), + () => frappe.click_link('Test Product 2'), + () => frappe.timeout(0.2), + () => frappe.click_element(`.cart-items [data-item-code="Test Product 2"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.number-pad [data-value="Rate"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.number-pad [data-value="2"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.number-pad [data-value="5"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.number-pad [data-value="0"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.number-pad [data-value="Pay"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.frappe-control [data-value="4"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.frappe-control [data-value="5"]`), + () => frappe.timeout(0.2), + () => frappe.click_element(`.frappe-control [data-value="0"]`), + () => frappe.timeout(0.2), + () => frappe.click_button('Submit'), + () => frappe.click_button('Yes'), + () => frappe.timeout(3), + () => assert.ok(cur_frm.doc.docstatus==1, "Sales invoice created successfully"), + () => done() + ]); +}); \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js b/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js new file mode 100644 index 00000000000..d9b8cf8274b --- /dev/null +++ b/erpnext/selling/page/point_of_sale/tests/test_pos_settings.js @@ -0,0 +1,17 @@ +QUnit.test("test:POS Settings", function(assert) { + assert.expect(1); + let done = assert.async(); + + frappe.run_serially([ + () => frappe.set_route('Form', 'POS Settings'), + () => cur_frm.set_value('is_online', 1), + () => frappe.timeout(0.2), + () => cur_frm.save(), + () => frappe.timeout(1), + () => frappe.ui.toolbar.clear_cache(), + () => frappe.timeout(10), + () => assert.ok(cur_frm.doc.is_online==1, "Enabled online"), + () => frappe.timeout(2), + () => done() + ]); +}); \ No newline at end of file diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 80ef70805a2..8d084dcb8cf 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -79,7 +79,7 @@ def get_item_details(args): and out.warehouse and out.stock_qty > 0: if out.has_serial_no: - out.serial_no = get_serial_no(out) + out.serial_no = get_serial_no(out, args.serial_no) if out.has_batch_no and not args.get("batch_no"): out.batch_no = get_batch_no(out.item_code, out.warehouse, out.qty) @@ -554,7 +554,8 @@ def get_gross_profit(out): return out @frappe.whitelist() -def get_serial_no(args): +def get_serial_no(args, serial_nos=None): + serial_no = None if isinstance(args, basestring): args = json.loads(args) args = frappe._dict(args) @@ -568,4 +569,9 @@ def get_serial_no(args): args = json.dumps({"item_code": args.get('item_code'),"warehouse": args.get('warehouse'),"stock_qty": args.get('stock_qty')}) args = process_args(args) serial_no = get_serial_nos_by_fifo(args) - return serial_no + + if not serial_no and serial_nos: + # For POS + serial_no = serial_nos + + return serial_no diff --git a/erpnext/tests/ui/tests.txt b/erpnext/tests/ui/tests.txt index 6017f6f67b6..4b62dd6b96e 100644 --- a/erpnext/tests/ui/tests.txt +++ b/erpnext/tests/ui/tests.txt @@ -50,6 +50,8 @@ erpnext/schools/doctype/room/test_room.js erpnext/schools/doctype/instructor/test_instructor.js erpnext/stock/doctype/warehouse/test_warehouse.js erpnext/manufacturing/doctype/production_order/test_production_order.js #long +erpnext/selling/page/point_of_sale/tests/test_pos_settings.js +erpnext/selling/page/point_of_sale/tests/test_point_of_sale.js erpnext/accounts/page/pos/test_pos.js erpnext/selling/doctype/product_bundle/test_product_bundle.js erpnext/stock/doctype/delivery_note/test_delivery_note.js