Merge pull request #10600 from netchampfaris/pos-refactor
POS Online
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.js b/erpnext/accounts/doctype/pos_profile/pos_profile.js
index 03d84e9..97bbc122 100755
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.js
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.js
@@ -8,10 +8,6 @@
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', {
+ 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 6991da2..187454e 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,
@@ -975,6 +974,38 @@
"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,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
"fieldname": "customer_group",
"fieldtype": "Link",
"hidden": 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 0000000..e69de29
--- /dev/null
+++ b/erpnext/accounts/doctype/pos_settings/__init__.py
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 0000000..1a14618
--- /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 0000000..a04558d
--- /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 0000000..736d36e
--- /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/accounts/doctype/pos_settings/test_pos_settings.js b/erpnext/accounts/doctype/pos_settings/test_pos_settings.js
new file mode 100644
index 0000000..639c94e
--- /dev/null
+++ b/erpnext/accounts/doctype/pos_settings/test_pos_settings.js
@@ -0,0 +1,23 @@
+/* eslint-disable */
+// rename this file from _test_[name] to test_[name] to activate
+// and remove above this line
+
+QUnit.test("test: POS Settings", function (assert) {
+ let done = assert.async();
+
+ // number of asserts
+ assert.expect(1);
+
+ frappe.run_serially([
+ // insert a new POS Settings
+ () => frappe.tests.make('POS Settings', [
+ // values to be set
+ {key: 'value'}
+ ]),
+ () => {
+ assert.equal(cur_frm.doc.key, 'value');
+ },
+ () => done()
+ ]);
+
+});
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index a29defa..065fb94 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -313,7 +313,7 @@
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 33b41e9..2f42524 100644
--- a/erpnext/accounts/page/pos/pos.js
+++ b/erpnext/accounts/page/pos/pos.js
@@ -8,7 +8,16 @@
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/print_format/point_of_sale/point_of_sale.json b/erpnext/accounts/print_format/point_of_sale/point_of_sale.json
index 28c853c..4e69cad 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": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Monospace;\n\t\tline-height: 200%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n<p class=\"text-center\">\n\t{{ company }}<br>\n\t{{ __(\"POS No : \") }} {{ offline_pos_name }}<br>\n</p>\n<p>\n\t<b>{{ __(\"Customer\") }}:</b> {{ customer }}<br>\n</p>\n\n<p>\n\t<b>{{ __(\"Date\") }}:</b> {{ dateutil.global_date_format(posting_date) }}<br>\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ __(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ __(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ __(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{% for item in items %}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_name }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ format_number(item.qty, null,precision(\"difference\")) }}<br>@ {{ format_currency(item.rate, currency) }}</td>\n\t\t\t<td class=\"text-right\">{{ format_currency(item.amount, currency) }}</td>\n\t\t</tr>\n\t\t{% endfor %}\n\t</tbody>\n</table>\n\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t{{ __(\"Net Total\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(total, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{% for row in taxes %}\n\t\t{% if not row.included_in_print_rate %}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t{{ row.description }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(row.tax_amount, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{% endif %}\n\t\t{% endfor %}\n\t\t{% if discount_amount %}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ __(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(discount_amount, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{% endif %}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ __(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(grand_total, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ __(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(paid_amount, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n\n\n<hr>\n<p>{{ terms }}</p>\n<p class=\"text-center\">{{ __(\"Thank you, please visit again.\") }}</p>",
"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 d11b668..50c64b5 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -435,6 +435,7 @@
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 0000000..64a3a7c
--- /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 0660b39..13fdcf1 100644
--- a/erpnext/public/css/erpnext.css
+++ b/erpnext/public/css/erpnext.css
@@ -308,16 +308,6 @@
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"] .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 0000000..f66abc8
--- /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 be55406..f5bcf1c 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 @@
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);
+ });
}
}
}
@@ -1113,7 +1117,7 @@
},
});
-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,
@@ -1122,6 +1126,7 @@
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 0000000..075c9ca
--- /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 = '<!--[if gt IE ' + (++v) + ']><i><![endif]-->',
+ 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 = '<table><tbody>' + data + '</tbody></table>';
+ 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 08630e5..3e2414e 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 @@
}
});
+ if(this.show_dialog) {
+ let d = this.item;
+ this.dialog.set_value('serial_no', d.serial_no);
+ }
+
this.dialog.show();
},
@@ -140,6 +146,7 @@
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 6c616c9..de46c53 100644
--- a/erpnext/public/less/erpnext.less
+++ b/erpnext/public/less/erpnext.less
@@ -364,17 +364,6 @@
.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 @@
.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 0000000..9653a82
--- /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/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..9cd2a49
--- /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(`
+ <div class="pos">
+ <section class="cart-container">
+
+ </section>
+ <section class="item-container">
+
+ </section>
+ </div>
+ `);
+ }
+
+ 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 = $('<div>');
+ 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(`
+ <div class="pos-cart">
+ <div class="customer-field">
+ </div>
+ <div class="cart-wrapper">
+ <div class="list-item-table">
+ <div class="list-item list-item--head">
+ <div class="list-item__content list-item__content--flex-1.5 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 class="taxes-and-totals">
+ ${this.get_taxes_and_totals()}
+ </div>
+ <div class="discount-amount">
+ ${this.get_discount_amount()}
+ </div>
+ <div class="grand-total">
+ ${this.get_grand_total()}
+ </div>
+ </div>
+ </div>
+ <div class="number-pad-container">
+ </div>
+ </div>
+ `);
+ 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 `
+ <div class="list-item">
+ <div class="list-item__content text-muted">${__('Grand Total')}</div>
+ <div class="list-item__content list-item__content--flex-2 grand-total-value">0.00</div>
+ </div>
+ `;
+ }
+
+ get_discount_amount() {
+ const get_currency_symbol = window.get_currency_symbol;
+
+ return `
+ <div class="list-item">
+ <div class="list-item__content list-item__content--flex-2 text-muted">${__('Discount')}</div>
+ <div class="list-item__content discount-inputs">
+ <input type="text"
+ class="form-control additional_discount_percentage text-right"
+ placeholder="% 0.00"
+ >
+ <input type="text"
+ class="form-control discount_amount text-right"
+ placeholder="${get_currency_symbol(this.frm.doc.currency)} 0.00"
+ >
+ </div>
+ </div>
+ `;
+ }
+
+ get_taxes_and_totals() {
+ return `
+ <div class="list-item">
+ <div class="list-item__content list-item__content--flex-2 text-muted">${__('Net Total')}</div>
+ <div class="list-item__content net-total">0.00</div>
+ </div>
+ <div class="list-item">
+ <div class="list-item__content list-item__content--flex-2 text-muted">${__('Taxes')}</div>
+ <div class="list-item__content taxes">0.00</div>
+ </div>
+ `;
+ }
+
+ 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 `
+ <div>
+ <span>${tax.description}</span>
+ <span class="text-right bold">
+ ${format_currency(tax.tax_amount, currency)}
+ </span>
+ </div>
+ `;
+ }).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 `
+ <div class="list-item indicator ${indicator_class}" data-item-code="${item.item_code}" title="Item: ${item.item_name} Available Qty: ${item.actual_qty}">
+ <div class="item-name list-item__content list-item__content--flex-1.5 ellipsis">
+ ${item.item_name}
+ </div>
+ <div class="quantity list-item__content text-right">
+ ${get_quantity_html(item.qty)}
+ </div>
+ <div class="discount list-item__content text-right">
+ ${item.discount_percentage}%
+ </div>
+ <div class="rate list-item__content text-right">
+ ${rate}
+ </div>
+ </div>
+ `;
+
+ function get_quantity_html(value) {
+ return `
+ <div class="input-group input-group-xs">
+ <span class="input-group-btn">
+ <button class="btn btn-default btn-xs" data-action="increment">+</button>
+ </span>
+
+ <input class="form-control" type="number" value="${value}">
+
+ <span class="input-group-btn">
+ <button class="btn btn-default btn-xs" data-action="decrement">-</button>
+ </span>
+ </div>
+ `;
+ }
+ }
+
+ 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(`
+ <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() {
+ // 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 = '<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];
+
+ 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 = `
+ <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>
+ </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">
+ ${price_list_rate}
+ </span>
+ </a>
+ </div>
+ </div>
+ `;
+
+ 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(`
+ <div class="number-pad">
+ ${this.button_array.map(get_row).join("")}
+ </div>
+ `);
+
+ function get_row(row) {
+ return '<div class="num-row">' + row.map(get_col).join("") + '</div>';
+ }
+
+ function get_col(col) {
+ return `<div class="num-col" data-value="${col}"><div>${col}</div></div>`;
+ }
+
+ 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 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
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 0000000..8ed288b
--- /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/test_point_of_sale.js b/erpnext/selling/page/point_of_sale/test_point_of_sale.js
new file mode 100644
index 0000000..d5053b5
--- /dev/null
+++ b/erpnext/selling/page/point_of_sale/test_point_of_sale.js
@@ -0,0 +1,56 @@
+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(2),
+ () => assert.ok(cur_frm.doc.is_online==1, "Enabled online"),
+ () => frappe.timeout(2),
+ () => done()
+ ]);
+});
+
+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 [title="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(5),
+ () => assert.ok(cur_frm.doc.docstatus==1, "Sales invoice created successfully"),
+ () => done()
+ ]);
+});
\ No newline at end of file
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 80ef708..8d084dc 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -79,7 +79,7 @@
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 @@
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 @@
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 6017f6f..9bb520f 100644
--- a/erpnext/tests/ui/tests.txt
+++ b/erpnext/tests/ui/tests.txt
@@ -50,6 +50,7 @@
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/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