summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorJoseph Caburnay <jcb@odoo.com>2020-05-05 07:47:10 +0000
committerPierre Masereel <pim@odoo.com>2020-05-12 12:48:58 +0000
commitc6397ab3b3654da336a4269a81d5d91016baf520 (patch)
tree31ce3a609f2b16076240953f142dbdf63b7c16af /addons
parentcbd63605702cb7fdc7c85132f96e7d04b0e559a7 (diff)
[REF] point_of_sale: migration to OWL framework
Diffstat (limited to 'addons')
-rw-r--r--addons/point_of_sale/__manifest__.py65
-rw-r--r--addons/point_of_sale/controllers/main.py15
-rw-r--r--addons/point_of_sale/static/src/css/pos.css79
-rw-r--r--addons/point_of_sale/static/src/js/Chrome.js426
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js23
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js87
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js161
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js36
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js49
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js91
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js38
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js37
-rw-r--r--addons/point_of_sale/static/src/js/ClassRegistry.js262
-rw-r--r--addons/point_of_sale/static/src/js/ComponentRegistry.js29
-rw-r--r--addons/point_of_sale/static/src/js/Gui.js60
-rw-r--r--addons/point_of_sale/static/src/js/Misc/Draggable.js143
-rw-r--r--addons/point_of_sale/static/src/js/Misc/NotificationSound.js19
-rw-r--r--addons/point_of_sale/static/src/js/Misc/NumberBuffer.js277
-rw-r--r--addons/point_of_sale/static/src/js/PopupControllerMixin.js44
-rw-r--r--addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js60
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js20
-rw-r--r--addons/point_of_sale/static/src/js/Popups/EditListInput.js19
-rw-r--r--addons/point_of_sale/static/src/js/Popups/EditListPopup.js105
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js26
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ErrorPopup.js24
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js44
-rw-r--r--addons/point_of_sale/static/src/js/Popups/NumberPopup.js71
-rw-r--r--addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js29
-rw-r--r--addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js27
-rw-r--r--addons/point_of_sale/static/src/js/Popups/SelectionPopup.js63
-rw-r--r--addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js39
-rw-r--r--addons/point_of_sale/static/src/js/Popups/TextInputPopup.js34
-rw-r--r--addons/point_of_sale/static/src/js/PosComponent.js59
-rw-r--r--addons/point_of_sale/static/src/js/Registries.js11
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js22
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js112
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js21
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js167
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js17
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js422
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js23
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js39
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js80
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js67
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js47
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js110
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js49
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js338
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js72
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js29
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js47
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js132
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js102
-rw-r--r--addons/point_of_sale/static/src/js/barcode_reader.js93
-rw-r--r--addons/point_of_sale/static/src/js/chrome.js937
-rw-r--r--addons/point_of_sale/static/src/js/custom_hooks.js141
-rw-r--r--addons/point_of_sale/static/src/js/db.js1
-rw-r--r--addons/point_of_sale/static/src/js/debug_manager.js20
-rw-r--r--addons/point_of_sale/static/src/js/gui.js464
-rw-r--r--addons/point_of_sale/static/src/js/main.js29
-rw-r--r--addons/point_of_sale/static/src/js/models.js467
-rw-r--r--addons/point_of_sale/static/src/js/popups.js343
-rw-r--r--addons/point_of_sale/static/src/js/printers.js74
-rw-r--r--addons/point_of_sale/static/src/js/screens.js2625
-rw-r--r--addons/point_of_sale/static/src/js/utils.js21
-rw-r--r--addons/point_of_sale/static/src/js/widget_base.js83
-rw-r--r--addons/point_of_sale/static/src/xml/Chrome.xml126
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml19
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml93
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml34
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml29
-rw-r--r--addons/point_of_sale/static/src/xml/Misc/Draggable.xml8
-rw-r--r--addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml8
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml27
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/EditListInput.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml22
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml36
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml70
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml39
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml29
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/SaleDetailsReport.xml75
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetails.xml95
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml129
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml19
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml74
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml18
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml77
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml81
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml31
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml115
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml15
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml15
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml37
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml23
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml66
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml21
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml30
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml38
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml47
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml196
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml33
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml10
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml36
-rw-r--r--addons/point_of_sale/static/src/xml/debug_manager.xml10
-rw-r--r--addons/point_of_sale/static/src/xml/pos.xml1778
-rw-r--r--addons/point_of_sale/static/tests/tours/Chrome.tour.js74
-rw-r--r--addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js75
-rw-r--r--addons/point_of_sale/static/tests/tours/ProductScreen.tour.js99
-rw-r--r--addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js35
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js42
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js30
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js69
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js195
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js177
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js59
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js39
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/utils.js144
-rw-r--r--addons/point_of_sale/static/tests/tours/point_of_sale.js38
-rw-r--r--addons/point_of_sale/static/tests/unit/helpers/test_env.js43
-rw-r--r--addons/point_of_sale/static/tests/unit/helpers/test_main.js6
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js135
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js414
-rw-r--r--addons/point_of_sale/static/tests/unit/test_PaymentScreen.js310
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ProductScreen.js594
-rw-r--r--addons/point_of_sale/static/tests/unit/test_popups.js180
-rw-r--r--addons/point_of_sale/tests/__init__.py1
-rw-r--r--addons/point_of_sale/tests/test_frontend.py21
-rw-r--r--addons/point_of_sale/tests/test_js.py22
-rw-r--r--addons/point_of_sale/views/customer_facing_display.xml (renamed from addons/point_of_sale/views/point_of_sale.xml)24
-rw-r--r--addons/point_of_sale/views/point_of_sale_template.xml18
-rw-r--r--addons/point_of_sale/views/pos_assets_common.xml108
-rw-r--r--addons/point_of_sale/views/pos_assets_index.xml64
-rw-r--r--addons/point_of_sale/views/pos_assets_qunit.xml83
-rw-r--r--addons/point_of_sale/views/pos_templates.xml67
162 files changed, 10442 insertions, 6628 deletions
diff --git a/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/__manifest__.py
index 8ddde9914ba..047bccb3665 100644
--- a/addons/point_of_sale/__manifest__.py
+++ b/addons/point_of_sale/__manifest__.py
@@ -17,8 +17,9 @@
'wizard/pos_box.xml',
'wizard/pos_details.xml',
'wizard/pos_payment.xml',
- 'views/pos_templates.xml',
- 'views/point_of_sale_template.xml',
+ 'views/pos_assets_common.xml',
+ 'views/pos_assets_index.xml',
+ 'views/pos_assets_qunit.xml',
'views/point_of_sale_report.xml',
'views/point_of_sale_view.xml',
'views/pos_order_view.xml',
@@ -30,7 +31,7 @@
'views/pos_config_view.xml',
'views/pos_session_view.xml',
'views/point_of_sale_sequence.xml',
- 'views/point_of_sale.xml',
+ 'views/customer_facing_display.xml',
'data/point_of_sale_data.xml',
'views/pos_order_report_view.xml',
'views/account_statement_view.xml',
@@ -46,6 +47,62 @@
],
'installable': True,
'application': True,
- 'qweb': ['static/src/xml/pos.xml'],
+ 'qweb': [
+ 'static/src/xml/Chrome.xml',
+ 'static/src/xml/debug_manager.xml',
+ 'static/src/xml/Screens/ProductScreen/ProductScreen.xml',
+ 'static/src/xml/Screens/ClientListScreen/ClientLine.xml',
+ 'static/src/xml/Screens/ClientListScreen/ClientDetails.xml',
+ 'static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml',
+ 'static/src/xml/Screens/ClientListScreen/ClientListScreen.xml',
+ 'static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml',
+ 'static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml',
+ 'static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml',
+ 'static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml',
+ 'static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml',
+ 'static/src/xml/Screens/PaymentScreen/PaymentScreen.xml',
+ 'static/src/xml/Screens/ProductScreen/Orderline.xml',
+ 'static/src/xml/Screens/ProductScreen/OrderSummary.xml',
+ 'static/src/xml/Screens/ProductScreen/OrderWidget.xml',
+ 'static/src/xml/Screens/ProductScreen/NumpadWidget.xml',
+ 'static/src/xml/Screens/ProductScreen/ActionpadWidget.xml',
+ 'static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml',
+ 'static/src/xml/Screens/ProductScreen/CategoryButton.xml',
+ 'static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml',
+ 'static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml',
+ 'static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml',
+ 'static/src/xml/Screens/ProductScreen/ProductItem.xml',
+ 'static/src/xml/Screens/ProductScreen/ProductList.xml',
+ 'static/src/xml/Screens/ProductScreen/ProductsWidget.xml',
+ 'static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml',
+ 'static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml',
+ 'static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml',
+ 'static/src/xml/Screens/ScaleScreen/ScaleScreen.xml',
+ 'static/src/xml/ChromeWidgets/OrderSelector.xml',
+ 'static/src/xml/ChromeWidgets/CashierName.xml',
+ 'static/src/xml/ChromeWidgets/ProxyStatus.xml',
+ 'static/src/xml/ChromeWidgets/SyncNotification.xml',
+ 'static/src/xml/ChromeWidgets/HeaderButton.xml',
+ 'static/src/xml/ChromeWidgets/SaleDetailsButton.xml',
+ 'static/src/xml/SaleDetailsReport.xml',
+ 'static/src/xml/Misc/Draggable.xml',
+ 'static/src/xml/Misc/NotificationSound.xml',
+ 'static/src/xml/ChromeWidgets/DebugWidget.xml',
+ 'static/src/xml/Popups/ErrorPopup.xml',
+ 'static/src/xml/Popups/ErrorBarcodePopup.xml',
+ 'static/src/xml/Popups/ConfirmPopup.xml',
+ 'static/src/xml/Popups/TextInputPopup.xml',
+ 'static/src/xml/Popups/TextAreaPopup.xml',
+ 'static/src/xml/Popups/ErrorTracebackPopup.xml',
+ 'static/src/xml/Popups/SelectionPopup.xml',
+ 'static/src/xml/Popups/EditListInput.xml',
+ 'static/src/xml/Popups/EditListPopup.xml',
+ 'static/src/xml/Popups/NumberPopup.xml',
+ 'static/src/xml/Popups/OfflineErrorPopup.xml',
+ 'static/src/xml/Popups/OrderImportPopup.xml',
+ 'static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml',
+ 'static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml',
+ 'static/src/xml/ChromeWidgets/ClientScreenButton.xml',
+ ],
'website': 'https://www.odoo.com/page/point-of-sale-shop',
}
diff --git a/addons/point_of_sale/controllers/main.py b/addons/point_of_sale/controllers/main.py
index bdcd7555118..34788618ea8 100644
--- a/addons/point_of_sale/controllers/main.py
+++ b/addons/point_of_sale/controllers/main.py
@@ -43,6 +43,21 @@ class PosController(http.Controller):
}
return request.render('point_of_sale.index', qcontext=context)
+ @http.route('/pos/web/tests', type='http', auth="user")
+ def test_suite(self, mod=None, **kwargs):
+ domain = [
+ ('state', '=', 'opened'),
+ ('user_id', '=', request.session.uid),
+ ('rescue', '=', False)
+ ]
+ pos_session = request.env['pos.session'].sudo().search(domain, limit=1)
+ session_info = request.env['ir.http'].session_info()
+ session_info['user_context']['allowed_company_ids'] = pos_session.company_id.ids
+ context = {
+ 'session_info': session_info,
+ }
+ return request.render('point_of_sale.qunit_suite', qcontext=context)
+
@http.route('/pos/sale_details_report', type='http', auth='user')
def print_sale_details(self, date_start=False, date_stop=False, **kw):
r = request.env['report.point_of_sale.report_saledetails']
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
index 268d7b190f2..af5c7b57175 100644
--- a/addons/point_of_sale/static/src/css/pos.css
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -1772,6 +1772,21 @@ td {
margin-left: 120px;
}
+/* These css declarations is to make sure that the list of clients
+ * always occupies the remaining space regardless when the client
+ * details is displayed or not.
+ */
+ .clientlist-screen .client-window {
+ display: flex;
+ flex-flow: column;
+ height: 100%;
+}
+
+.clientlist-screen .client-window section.list {
+ flex: 1;
+ overflow: auto;
+}
+
/* ********* The OrderWidget ********* */
@@ -2102,6 +2117,9 @@ td {
.pos .debug-widget .content{
overflow: hidden;
}
+.pos .debug-widget header {
+ position: relative;
+}
.pos .debug-widget h1{
background:black;
padding-top: 10px;
@@ -2164,9 +2182,11 @@ td {
background-color: rgba(0,0,0,0.5);
z-index:1000;
}
+.pos .modal-dialog header{
+ position: relative;
+}
.pos .modal-dialog .popup{
position: absolute;
- top: 0; left: 0; right: 0; bottom: 0;
margin: auto;
width:500px;
height:400px;
@@ -2179,6 +2199,10 @@ td {
z-index:1200;
font-family: 'Lato';
font-family: Lato;
+ /* position the popup at center and and still making it draggable*/
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
}
.pos .modal-dialog .popup-confirm{
height: 250px;
@@ -2238,6 +2262,11 @@ td {
background: rgba(0,0,0,0.05);
}
+
+.pos .popup .button.dont-show-again {
+ width: 130px;
+}
+
.pos .popup .button.icon {
width: 40px;
font-size: 20px;
@@ -2287,12 +2316,12 @@ td {
box-sizing: border-box;
width: 80%;
}
-.pos .popup .packlot-lines{
+.pos .popup .list-lines{
overflow: auto;
height: 250px;
margin: 10px;
}
-.pos .popup .packlot-line-input {
+.pos .popup .list-line-input {
margin: 3px;
}
@@ -2387,7 +2416,6 @@ td {
}
.pos .popup-password .popup-numpad {
width: 190px;
- text-align: left;
}
.pos .popup-password .popup-input {
width: 70%;
@@ -2474,3 +2502,46 @@ td {
.pos .not-supported-browser img{
border-collapse: separate;
}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity .2s;
+}
+
+.fade-enter, .fade-leave-to {
+ opacity: 0;
+}
+
+.swing-enter-active, .swing-leave-active {
+ transition: opacity 0.8s;
+}
+
+.swing-enter, .swing-leave-to {
+ opacity: 0;
+}
+
+/*
+ We block the top-header when a temp screen is displayed.
+ Similar to blocking the whole ui when a popup is displayed.
+*/
+.pos .block-top-header {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height:100%;
+ background-color: rgba(0,0,0,0.5);
+ z-index:1000;
+}
+
+.drag-handle {
+ cursor: move; /* fallback if grab cursor is unsupported */
+ cursor: grab;
+ cursor: -moz-grab;
+ cursor: -webkit-grab;
+}
+
+.drag-handle:active {
+ cursor: grabbing;
+ cursor: -moz-grabbing;
+ cursor: -webkit-grabbing;
+}
diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js
new file mode 100644
index 00000000000..dbfeab4bc5a
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Chrome.js
@@ -0,0 +1,426 @@
+odoo.define('point_of_sale.Chrome', function(require) {
+ 'use strict';
+
+ const { useState, useRef } = owl.hooks;
+ const { debounce } = owl.utils;
+ const { loadCSS } = require('web.ajax');
+ const { useListener } = require('web.custom_hooks');
+ const { CrashManager } = require('web.CrashManager');
+ const { BarcodeEvents } = require('barcodes.BarcodeEvents');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const Registries = require('point_of_sale.Registries');
+
+ // This is kind of a trick.
+ // We get a reference to the whole exports so that
+ // when we create an instance of one of the classes,
+ // we instantiate the extended one.
+ const models = require('point_of_sale.models');
+
+ /**
+ * Chrome is the root component of the PoS App.
+ */
+ class Chrome extends PopupControllerMixin(PosComponent) {
+ constructor() {
+ super(...arguments);
+ useListener('show-main-screen', this.__showScreen);
+ useListener('toggle-debug-widget', debounce(this._toggleDebugWidget, 100));
+ useListener('show-temp-screen', this.__showTempScreen);
+ useListener('close-temp-screen', this.__closeTempScreen);
+ useListener('close-pos', this._closePos);
+ useListener('loading-skip-callback', () => this._loadingSkipCallback());
+ useListener('play-sound', this._onPlaySound);
+ useListener('set-sync-status', this._onSetSyncStatus);
+ NumberBuffer.activate();
+
+ this.state = useState({
+ uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING'
+ debugWidgetIsShown: true,
+ hasBigScrollBars: false,
+ sound: { src: null },
+ });
+
+ this.loading = useState({
+ message: 'Loading',
+ skipButtonIsShown: false,
+ });
+
+ this.mainScreen = useState({ name: null, component: null });
+ this.mainScreenProps = {};
+
+ this.tempScreen = useState({ isShown: false, name: null, component: null });
+ this.tempScreenProps = {};
+
+ this.progressbar = useRef('progressbar');
+
+ this.previous_touch_y_coordinate = -1;
+ }
+
+ // OVERLOADED METHODS //
+
+ mounted() {
+ // remove default webclient handlers that induce click delay
+ $(document).off();
+ $(window).off();
+ $('html').off();
+ $('body').off();
+ // The above lines removed the bindings, but we really need them for the barcode
+ BarcodeEvents.start();
+ }
+ willUnmount() {
+ BarcodeEvents.stop();
+ }
+ destroy() {
+ super.destroy(...arguments);
+ this.env.pos.destroy();
+ }
+ catchError(error) {
+ console.error(error);
+ }
+
+ // GETTERS //
+
+ get clientScreenButtonIsShown() {
+ return (
+ this.env.pos.config.use_proxy && this.env.pos.config.iface_customer_facing_display
+ );
+ }
+ /**
+ * Startup screen can be based on pos config so the startup screen
+ * is only determined after pos data is completely loaded.
+ *
+ * NOTE: Wait for pos data to be completed before calling this getter.
+ */
+ get startScreen() {
+ if (this.state.uiState !== 'READY') {
+ console.warn(
+ `Accessing startScreen of Chrome component before 'state.uiState' to be 'READY' is not recommended.`
+ );
+ }
+ return { name: 'ProductScreen' };
+ }
+
+ // CONTROL METHODS //
+
+ /**
+ * Call this function after the Chrome component is mounted.
+ * This will load pos and assign it to the environment.
+ */
+ async start() {
+ try {
+ // Instead of passing chrome to the instantiation the PosModel,
+ // we inject functions needed by pos.
+ // This way, we somehow decoupled Chrome from PosModel.
+ // We can then test PosModel independently from Chrome by supplying
+ // mocked version of these default attributes.
+ const posModelDefaultAttributes = {
+ rpc: this.rpc.bind(this),
+ session: this.env.session,
+ do_action: this.props.webClient.do_action.bind(this.props.webClient),
+ setLoadingMessage: this.setLoadingMessage.bind(this),
+ showLoadingSkip: this.showLoadingSkip.bind(this),
+ setLoadingProgress: this.setLoadingProgress.bind(this),
+ };
+ this.env.pos = new models.PosModel(posModelDefaultAttributes);
+ await this.env.pos.ready;
+ this._buildChrome();
+ this.env.pos.set(
+ 'selectedCategoryId',
+ this.env.pos.config.iface_start_categ_id
+ ? this.env.pos.config.iface_start_categ_id[0]
+ : 0
+ );
+ this.state.uiState = 'READY';
+ this.env.pos.on('change:selectedOrder', this._showSavedScreen, this);
+ this._showStartScreen();
+ this.env.pos.push_orders(); // push order in the background, no need to await
+ // Allow using the app even if not all the images are loaded.
+ // Basically, preload the images in the background.
+ setTimeout(() => this._preloadImages());
+ } catch (error) {
+ let title = 'Unknown Error',
+ body;
+
+ if (error.message && [100, 200, 404, -32098].includes(error.message.code)) {
+ // this is the signature of rpc error
+ if (error.message.code === -32098) {
+ title = 'Network Failure (XmlHttpRequestError)';
+ body =
+ 'The Point of Sale could not be loaded due to a network problem.\n' +
+ 'Please check your internet connection.';
+ } else if (error.message.code === 200) {
+ title = error.message.data.message || this.env._t('Server Error');
+ body =
+ error.message.data.debug ||
+ this.env._t(
+ 'The server encountered an error while receiving your order.'
+ );
+ }
+ } else if (error instanceof Error) {
+ title = error.message;
+ body = error.stack;
+ }
+
+ await this.showPopup('ErrorTracebackPopup', {
+ title,
+ body,
+ exitButtonIsShown: true,
+ });
+ }
+ }
+
+ // EVENT HANDLERS //
+
+ _showStartScreen() {
+ const { name, props } = this.startScreen;
+ this.showScreen(name, props);
+ }
+ /**
+ * Show the screen saved in the order when the `selectedOrder` of pos is changed.
+ * @param {models.PosModel} pos
+ * @param {models.Order} newSelectedOrder
+ */
+ _showSavedScreen(pos, newSelectedOrder) {
+ const { name, props } = this._getSavedScreen(newSelectedOrder);
+ this.showScreen(name, props);
+ }
+ _getSavedScreen(order) {
+ return order.get_screen_data('screen') || { name: 'ProductScreen' };
+ }
+ __showTempScreen(event) {
+ const { name, props, resolve } = event.detail;
+ this.tempScreen.isShown = true;
+ this.tempScreen.name = name;
+ this.tempScreen.component = this.constructor.components[name];
+ this.tempScreenProps = Object.assign({}, props, { resolve });
+ }
+ __closeTempScreen() {
+ this.tempScreen.isShown = false;
+ }
+ __showScreen({ detail: { name, props } }) {
+ // 1. Set the information of the screen to display.
+ this.mainScreen.name = name;
+ this.mainScreen.component = this.constructor.components[name];
+ this.mainScreenProps = props || {};
+ // 2. Save the screen to the order.
+ // - This screen is shown when the order is selected.
+ this._setScreenData(name, props);
+ }
+ /**
+ * Set the latest screen to the current order. This is done so that
+ * when the order is selected again, the ui returns to the latest screen
+ * saved in the order.
+ *
+ * @param {string} name Screen name
+ * @param {Object} props props for the Screen component
+ */
+ _setScreenData(name, props) {
+ const order = this.env.pos.get_order();
+ if (order) {
+ order.set_screen_data('screen', { name, props });
+ }
+ }
+ async _closePos() {
+ // If pos is not properly loaded, we just go back to /web without
+ // doing anything in the order data.
+ if (!this.env.pos || this.env.pos.db.get_orders().length === 0) {
+ window.location = '/web#action=point_of_sale.action_client_pos_menu';
+ }
+
+ if (this.env.pos.db.get_orders().length) {
+ // If there are orders in the db left unsynced, we try to sync.
+ // If sync successful, close without asking.
+ // Otherwise, ask again saying that some orders are not yet synced.
+ try {
+ await this.env.pos.push_orders();
+ window.location = '/web#action=point_of_sale.action_client_pos_menu';
+ } catch (error) {
+ console.warn(error);
+ const reason = this.env.pos.get('failed')
+ ? this.env._t(
+ 'Some orders could not be submitted to ' +
+ 'the server due to configuration errors. ' +
+ 'You can exit the Point of Sale, but do ' +
+ 'not close the session before the issue ' +
+ 'has been resolved.'
+ )
+ : this.env._t(
+ 'Some orders could not be submitted to ' +
+ 'the server due to internet connection issues. ' +
+ 'You can exit the Point of Sale, but do ' +
+ 'not close the session before the issue ' +
+ 'has been resolved.'
+ );
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Offline Orders'),
+ body: reason,
+ });
+ if (confirmed) {
+ this.state.uiState = 'CLOSING';
+ this.loading.skipButtonIsShown = false;
+ this.setLoadingMessage(this.env._t('Closing ...'));
+ window.location = '/web#action=point_of_sale.action_client_pos_menu';
+ }
+ }
+ }
+ }
+ _toggleDebugWidget() {
+ this.state.debugWidgetIsShown = !this.state.debugWidgetIsShown;
+ }
+ _onPlaySound({ detail: name }) {
+ let src;
+ if (name === 'error') {
+ src = "/point_of_sale/static/src/sounds/error.wav";
+ } else if (name === 'bell') {
+ src = "/point_of_sale/static/src/sounds/bell.wav";
+ }
+ this.state.sound.src = src;
+ }
+ _onSetSyncStatus({ detail: { status, pending }}) {
+ this.env.pos.set('synch', { status, pending });
+ }
+
+ // TO PASS AS PARAMETERS //
+
+ setLoadingProgress(fac) {
+ if (this.progressbar.el) {
+ this.progressbar.el.style.width = `${Math.floor(fac * 100)}%`;
+ }
+ }
+ setLoadingMessage(msg, progress) {
+ this.loading.message = msg;
+ if (typeof progress !== 'undefined') {
+ this.setLoadingProgress(progress);
+ }
+ }
+ /**
+ * Show Skip button in the loading screen and allow to assign callback
+ * when the button is pressed.
+ *
+ * @param {Function} callback function to call when Skip button is pressed.
+ */
+ showLoadingSkip(callback) {
+ if (callback) {
+ this.loading.skipButtonIsShown = true;
+ this._loadingSkipCallback = callback;
+ }
+ }
+
+ // MISC METHODS //
+
+ _preloadImages() {
+ for (let product of this.env.pos.db.get_product_by_category(0)) {
+ const image = new Image();
+ image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ }
+ for (let category of Object.values(this.env.pos.db.category_by_id)) {
+ if (category.id == 0) continue;
+ const image = new Image();
+ image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
+ }
+ const staticImages = ['backspace.png', 'bc-arrow-big.png'];
+ for (let imageName of staticImages) {
+ const image = new Image();
+ image.src = `/point_of_sale/static/src/img/${imageName}`;
+ }
+ }
+
+ _buildChrome() {
+ if ($.browser.chrome) {
+ var chrome_version = $.browser.version.split('.')[0];
+ if (parseInt(chrome_version, 10) >= 50) {
+ loadCSS('/point_of_sale/static/src/css/chrome50.css');
+ }
+ }
+
+ if (this.env.pos.config.iface_big_scrollbars) {
+ this.state.uiState.hasBigScrollBars = true;
+ }
+
+ this._disableRubberbanding();
+ this._disableBackspaceBack();
+ this._replaceCrashmanager();
+ }
+ // replaces the error handling of the existing crashmanager which
+ // uses jquery dialog to display the error, to use the pos popup
+ // instead
+ _replaceCrashmanager() {
+ var self = this;
+ CrashManager.include({
+ show_error: function (error) {
+ if (self.env.pos) {
+ // self == this component
+ self.showPopup('ErrorTracebackPopup', {
+ title: error.type,
+ body: error.message + '\n' + error.data.debug + '\n',
+ });
+ } else {
+ // this == CrashManager instance
+ this._super(error);
+ }
+ },
+ });
+ }
+ _disableRubberbanding() {
+ var self = this;
+
+ document.body.addEventListener('touchstart', function (event) {
+ self.previous_touch_y_coordinate = event.touches[0].clientY;
+ });
+
+ // prevent the pos body from being scrollable.
+ document.body.addEventListener('touchmove', function (event) {
+ var node = event.target;
+ var current_touch_y_coordinate = event.touches[0].clientY;
+ var scrolling_down;
+
+ if (current_touch_y_coordinate < self.previous_touch_y_coordinate) {
+ scrolling_down = true;
+ } else {
+ scrolling_down = false;
+ }
+
+ while (node) {
+ if (
+ node.classList &&
+ node.classList.contains('touch-scrollable') &&
+ self._scrollable(node, scrolling_down)
+ ) {
+ return;
+ }
+ node = node.parentNode;
+ }
+ event.preventDefault();
+ });
+ }
+ // prevent backspace from performing a 'back' navigation
+ _disableBackspaceBack() {
+ $(document).on('keydown', function (e) {
+ if (e.which === 8 && !$(e.target).is('input, textarea')) {
+ e.preventDefault();
+ }
+ });
+ }
+ _scrollable(element, scrolling_down) {
+ var $element = $(element);
+ var scrollable = true;
+
+ if (!scrolling_down && $element.scrollTop() <= 0) {
+ scrollable = false;
+ } else if (
+ scrolling_down &&
+ $element.scrollTop() + $element.height() >= element.scrollHeight
+ ) {
+ scrollable = false;
+ }
+
+ return scrollable;
+ }
+ }
+ Chrome.template = 'Chrome';
+
+ Registries.Component.add(Chrome);
+
+ return Chrome;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js b/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js
new file mode 100644
index 00000000000..02e6196794d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.CashierName', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ // Previously UsernameWidget
+ class CashierName extends PosComponent {
+ get username() {
+ const cashier = this.env.pos.get_cashier();
+ if (cashier) {
+ return cashier.name;
+ } else {
+ return '';
+ }
+ }
+ }
+ CashierName.template = 'CashierName';
+
+ Registries.Component.add(CashierName);
+
+ return CashierName;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js
new file mode 100644
index 00000000000..38403b588f5
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js
@@ -0,0 +1,87 @@
+odoo.define('point_of_sale.ClientScreenButton', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ // Formerly ClientScreenWidget
+ class ClientScreenButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ status: 'failure' });
+ this._start();
+ }
+ get message() {
+ return {
+ success: '',
+ warning: this.env._t('Connected, Not Owned'),
+ failure: this.env._t('Disconnected'),
+ not_found: this.env._t('Client Screen Unsupported. Please upgrade the IoT Box'),
+ }[this.state.status];
+ }
+ async onClick() {
+ try {
+ const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
+ const ownership = await this.env.pos.proxy.take_ownership_over_client_screen(
+ renderedHtml
+ );
+ if (typeof ownership === 'string') {
+ ownership = JSON.parse(ownership);
+ }
+ if (ownership.status === 'success') {
+ this.state.status = 'success';
+ } else {
+ this.state.status = 'warning';
+ }
+ if (!this.env.pos.proxy.posbox_supports_display) {
+ this.env.pos.proxy.posbox_supports_display = true;
+ this._start();
+ }
+ } catch (error) {
+ if (typeof error == 'undefined') {
+ this.state.status = 'failure';
+ } else {
+ this.state.status = 'not_found';
+ }
+ }
+ }
+ _start() {
+ const self = this;
+ async function loop() {
+ if (self.env.pos.proxy.posbox_supports_display) {
+ try {
+ const ownership = await self.env.pos.proxy.test_ownership_of_client_screen();
+ if (typeof ownership === 'string') {
+ ownership = JSON.parse(ownership);
+ }
+ if (ownership.status === 'OWNER') {
+ self.state.status = 'success';
+ } else {
+ self.state.status = 'warning';
+ }
+ setTimeout(loop, 3000);
+ } catch (error) {
+ if (error.abort) {
+ // Stop the loop
+ return;
+ }
+ if (typeof error == 'undefined') {
+ self.state.status = 'failure';
+ } else {
+ self.state.status = 'not_found';
+ self.env.pos.proxy.posbox_supports_display = false;
+ }
+ setTimeout(loop, 3000);
+ }
+ }
+ }
+ loop();
+ }
+ }
+ ClientScreenButton.template = 'ClientScreenButton';
+
+ Registries.Component.add(ClientScreenButton);
+
+ return ClientScreenButton;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js
new file mode 100644
index 00000000000..f5158428820
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js
@@ -0,0 +1,161 @@
+odoo.define('point_of_sale.DebugWidget', function (require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const { useRef } = owl.hooks;
+ const { getFileAsText } = require('point_of_sale.utils');
+ const { parse } = require('web.field_utils');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class DebugWidget extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({
+ barcodeInput: '',
+ weightInput: '',
+ isPaidOrdersReady: false,
+ isUnpaidOrdersReady: false,
+ buffer: NumberBuffer.get(),
+ });
+
+ // NOTE: Perhaps this can still be improved.
+ // What we do here is loop thru the `event` elements
+ // then we assign animation that happens when the event is triggered
+ // in the proxy. E.g. if open_cashbox is sent, the open_cashbox element
+ // changes color from '#6CD11D' to '#1E1E1E' for a duration of 2sec.
+ this.eventElementsRef = {};
+ this.animations = {};
+ for (let eventName of ['open_cashbox', 'print_receipt', 'scale_read']) {
+ this.eventElementsRef[eventName] = useRef(eventName);
+ this.env.pos.proxy.add_notification(
+ eventName,
+ (() => {
+ if (this.animations[eventName]) {
+ this.animations[eventName].cancel();
+ }
+ const eventElement = this.eventElementsRef[eventName].el;
+ eventElement.style.backgroundColor = '#6CD11D';
+ this.animations[eventName] = eventElement.animate(
+ { backgroundColor: ['#6CD11D', '#1E1E1E'] },
+ 2000
+ );
+ }).bind(this)
+ );
+ }
+ }
+ mounted() {
+ NumberBuffer.on('buffer-update', this, this._onBufferUpdate);
+ }
+ willUnmount() {
+ NumberBuffer.off('buffer-update', this, this._onBufferUpdate);
+ }
+ toggleWidget() {
+ this.state.isShown = !this.state.isShown;
+ }
+ setWeight() {
+ var weightInKg = parse.float(this.state.weightInput);
+ if (!isNaN(weightInKg)) {
+ this.env.pos.proxy.debug_set_weight(weightInKg);
+ }
+ }
+ resetWeight() {
+ this.state.weightInput = '';
+ this.env.pos.proxy.debug_reset_weight();
+ }
+ barcodeScan() {
+ this.env.pos.barcode_reader.scan(this.state.barcodeInput);
+ }
+ barcodeScanEAN() {
+ const ean = this.env.pos.barcode_reader.barcode_parser.sanitize_ean(
+ this.state.barcodeInput || '0'
+ );
+ this.state.barcodeInput = ean;
+ this.env.pos.barcode_reader.scan(ean);
+ }
+ async deleteOrders() {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Delete Paid Orders ?'),
+ body: this.env._t(
+ 'This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.'
+ ),
+ });
+ if (confirmed) {
+ this.env.pos.db.remove_all_orders();
+ this.env.pos.set_synch('connected', 0);
+ }
+ }
+ async deleteUnpaidOrders() {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Delete Unpaid Orders ?'),
+ body: this.env._t(
+ 'This operation will destroy all unpaid orders in the browser. You will lose all the unsaved data and exit the point of sale. This operation cannot be undone.'
+ ),
+ });
+ if (confirmed) {
+ this.env.pos.db.remove_all_unpaid_orders();
+ window.location = '/';
+ }
+ }
+ _createBlob(contents) {
+ if (typeof contents !== 'string') {
+ contents = JSON.stringify(contents, null, 2);
+ }
+ return new Blob([contents]);
+ }
+ // IMPROVEMENT: Duplicated codes for downloading paid and unpaid orders.
+ // The implementation can be better.
+ preparePaidOrders() {
+ try {
+ this.paidOrdersBlob = this._createBlob(this.env.pos.export_paid_orders());
+ this.state.isPaidOrdersReady = true;
+ } catch (error) {
+ console.warn(error);
+ }
+ }
+ get paidOrdersFilename() {
+ return `${this.env._t('paid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`;
+ }
+ get paidOrdersURL() {
+ var URL = window.URL || window.webkitURL;
+ return URL.createObjectURL(this.paidOrdersBlob);
+ }
+ prepareUnpaidOrders() {
+ try {
+ this.unpaidOrdersBlob = this._createBlob(this.env.pos.export_unpaid_orders());
+ this.state.isUnpaidOrdersReady = true;
+ } catch (error) {
+ console.warn(error);
+ }
+ }
+ get unpaidOrdersFilename() {
+ return `${this.env._t('unpaid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`;
+ }
+ get unpaidOrdersURL() {
+ var URL = window.URL || window.webkitURL;
+ return URL.createObjectURL(this.unpaidOrdersBlob);
+ }
+ async importOrders(event) {
+ const file = event.target.files[0];
+ if (file) {
+ const report = this.env.pos.import_orders(await getFileAsText(file));
+ await this.showPopup('OrderImportPopup', { report });
+ }
+ }
+ refreshDisplay() {
+ this.env.pos.proxy.message('display_refresh', {});
+ }
+ _onBufferUpdate(buffer) {
+ this.state.buffer = buffer;
+ }
+ get bufferRepr() {
+ return `"${this.state.buffer}"`;
+ }
+ }
+ DebugWidget.template = 'DebugWidget';
+
+ Registries.Component.add(DebugWidget);
+
+ return DebugWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js
new file mode 100644
index 00000000000..84036ecbc75
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js
@@ -0,0 +1,36 @@
+odoo.define('point_of_sale.HeaderButton', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ // Previously HeaderButtonWidget
+ // This is the close session button
+ class HeaderButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ label: 'Close' });
+ this.confirmed = null;
+ }
+ get translatedLabel() {
+ return this.env._t(this.state.label);
+ }
+ onClick() {
+ if (!this.confirmed) {
+ this.state.label = 'Confirm';
+ this.confirmed = setTimeout(() => {
+ this.state.label = 'Close';
+ this.confirmed = null;
+ }, 2000);
+ } else {
+ this.trigger('close-pos');
+ }
+ }
+ }
+ HeaderButton.template = 'HeaderButton';
+
+ Registries.Component.add(HeaderButton);
+
+ return HeaderButton;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js
new file mode 100644
index 00000000000..1eed8ab4010
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js
@@ -0,0 +1,49 @@
+odoo.define('point_of_sale.OrderSelector', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ // Previously OrderSelectorWidget
+ class OrderSelector extends PosComponent {
+ mounted() {
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ selectOrder(order) {
+ this.env.pos.set_order(order);
+ }
+ addNewOrder() {
+ this.env.pos.add_new_order();
+ }
+ get orderList() {
+ return this.env.pos.get_order_list();
+ }
+ async deleteCurrentOrder() {
+ const order = this.env.pos.get_order();
+ if (!order) {
+ return;
+ } else if (!order.is_empty()) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Destroy Current Order ?'),
+ body: this.env._t('You will lose any data associated with the current order'),
+ });
+ if (confirmed) this.env.pos.delete_current_order();
+ } else {
+ this.env.pos.delete_current_order();
+ }
+ }
+ creationTime(order) {
+ return moment(order.creation_date).format('hh:mm');
+ }
+ }
+ OrderSelector.template = 'OrderSelector';
+
+ Registries.Component.add(OrderSelector);
+
+ return OrderSelector;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js b/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js
new file mode 100644
index 00000000000..98c24c025e4
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js
@@ -0,0 +1,91 @@
+odoo.define('point_of_sale.ProxyStatus', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ // Previously ProxyStatusWidget
+ class ProxyStatus extends PosComponent {
+ constructor() {
+ super(...arguments);
+ const initialProxyStatus = this.env.pos.proxy.get('status');
+ this.state = useState({
+ status: initialProxyStatus.status,
+ msg: initialProxyStatus.msg,
+ });
+ this.statuses = ['connected', 'connecting', 'disconnected', 'warning'];
+ this.index = 0;
+ }
+ mounted() {
+ this.env.pos.proxy.on('change:status', this, this._onChangeStatus);
+ }
+ willUnmount() {
+ this.env.pos.proxy.off('change:status', this, this._onChangeStatus);
+ }
+ async onClick() {
+ try {
+ await this.env.pos.connect_to_proxy();
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ this.showPopup('ErrorPopup', error);
+ }
+ }
+ }
+ _onChangeStatus(posProxy, statusChange) {
+ this._setStatus(statusChange.newValue);
+ }
+ _setStatus(newStatus) {
+ if (newStatus.status === 'connected') {
+ var warning = false;
+ var msg = '';
+ if (this.env.pos.config.iface_scan_via_proxy) {
+ var scannerStatus = newStatus.drivers.scanner
+ ? newStatus.drivers.scanner.status
+ : false;
+ if (scannerStatus != 'connected' && scannerStatus != 'connecting') {
+ warning = true;
+ msg += this.env._t('Scanner');
+ }
+ }
+ if (
+ this.env.pos.config.iface_print_via_proxy ||
+ this.env.pos.config.iface_cashdrawer
+ ) {
+ var printerStatus = newStatus.drivers.printer
+ ? newStatus.drivers.printer.status
+ : false;
+ if (printerStatus != 'connected' && printerStatus != 'connecting') {
+ warning = true;
+ msg = msg ? msg + ' & ' : msg;
+ msg += this.env._t('Printer');
+ }
+ }
+ if (this.env.pos.config.iface_electronic_scale) {
+ var scaleStatus = newStatus.drivers.scale
+ ? newStatus.drivers.scale.status
+ : false;
+ if (scaleStatus != 'connected' && scaleStatus != 'connecting') {
+ warning = true;
+ msg = msg ? msg + ' & ' : msg;
+ msg += this.env._t('Scale');
+ }
+ }
+ msg = msg ? msg + ' ' + this.env._t('Offline') : msg;
+
+ this.state.status = warning ? 'warning' : 'connected';
+ this.state.msg = msg;
+ } else {
+ this.state.status = newStatus.status;
+ this.state.msg = newStatus.msg || '';
+ }
+ }
+ }
+ ProxyStatus.template = 'ProxyStatus';
+
+ Registries.Component.add(ProxyStatus);
+
+ return ProxyStatus;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js
new file mode 100644
index 00000000000..e646547eec2
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js
@@ -0,0 +1,38 @@
+odoo.define('point_of_sale.SaleDetailsButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class SaleDetailsButton extends PosComponent {
+ async onClick() {
+ // IMPROVEMENT: Perhaps put this logic in a parent component
+ // so that for unit testing, we can check if this simple
+ // component correctly triggers an event.
+ const saleDetails = await this.rpc({
+ model: 'report.point_of_sale.report_saledetails',
+ method: 'get_sale_details',
+ args: [false, false, false, [this.env.pos.pos_session.id]],
+ });
+ const report = this.env.qweb.renderToString(
+ 'SaleDetailsReport',
+ Object.assign({}, saleDetails, {
+ date: new Date().toLocaleString(),
+ pos: this.env.pos,
+ })
+ );
+ const printResult = await this.env.pos.proxy.printer.print_receipt(report);
+ if (!printResult.successful) {
+ await this.showPopup('ErrorPopup', {
+ title: printResult.message.title,
+ body: printResult.message.body,
+ });
+ }
+ }
+ }
+ SaleDetailsButton.template = 'SaleDetailsButton';
+
+ Registries.Component.add(SaleDetailsButton);
+
+ return SaleDetailsButton;
+});
diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js b/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js
new file mode 100644
index 00000000000..5a4e158de3b
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js
@@ -0,0 +1,37 @@
+odoo.define('point_of_sale.SyncNotification', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ // Previously SynchNotificationWidget
+ class SyncNotification extends PosComponent {
+ constructor() {
+ super(...arguments);
+ const synch = this.env.pos.get('synch');
+ this.state = useState({ status: synch.status, msg: synch.pending });
+ }
+ mounted() {
+ this.env.pos.on(
+ 'change:synch',
+ (pos, synch) => {
+ this.state.status = synch.status;
+ this.state.msg = synch.pending;
+ },
+ this
+ );
+ }
+ willUnmount() {
+ this.env.pos.on('change:synch', null, this);
+ }
+ onClick() {
+ this.env.pos.push_orders(null, { show_error: true });
+ }
+ }
+ SyncNotification.template = 'SyncNotification';
+
+ Registries.Component.add(SyncNotification);
+
+ return SyncNotification;
+});
diff --git a/addons/point_of_sale/static/src/js/ClassRegistry.js b/addons/point_of_sale/static/src/js/ClassRegistry.js
new file mode 100644
index 00000000000..236817dcf5e
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ClassRegistry.js
@@ -0,0 +1,262 @@
+odoo.define('point_of_sale.ClassRegistry', function (require) {
+ 'use strict';
+
+ /**
+ * **Usage:**
+ * ```
+ * const Registry = new ClassRegistry();
+ *
+ * class A {}
+ * Registry.add(A);
+ *
+ * const AExt1 = A => class extends A {}
+ * Registry.extend(A, AExt1)
+ *
+ * const B = A => class extends A {}
+ * Registry.addByExtending(B, A)
+ *
+ * const AExt2 = A => class extends A {}
+ * Registry.extend(A, AExt2)
+ *
+ * Registry.get(A)
+ * // above returns: AExt2 -> AExt1 -> A
+ * // Basically, 'A' in the registry points to
+ * // the inheritance chain above.
+ *
+ * Registry.get(B)
+ * // above returns: B -> AExt2 -> AExt1 -> A
+ * // Even though B extends A before applying all
+ * // the extensions of A, when getting it from the
+ * // registry, the return points to a class with
+ * // inheritance chain that includes all the extensions
+ * // of 'A'.
+ *
+ * Registry.freeze()
+ * // Example 'B' above is lazy. Basically, it is only
+ * // computed when we call `get` from the registry.
+ * // If we know that no more dynamic inheritances will happen,
+ * // we can freeze the registry and cache the final form
+ * // of each class in the registry.
+ * ```
+ *
+ * IMPROVEMENT:
+ * * So far, mixin can be accomplished by creating a method
+ * the takes a class and returns a class expression. This is then
+ * used before the extends keyword like so:
+ *
+ * ```js
+ * class A {}
+ * Registry.add(A)
+ * const Mixin = x => class extends x {}
+ * // apply mixin
+ * // |
+ * // v
+ * const B = x => class extends Mixin(x) {}
+ * Registry.addByExtending(B, A)
+ * ```
+ *
+ * In the example, `|B| => B -> Mixin -> A`, and this is pretty convenient
+ * already. However, this can still be improved since classes are only
+ * compiled after `Registry.freeze()`. Perhaps, we can make the
+ * Mixins extensible as well, such as so:
+ *
+ * ```
+ * class A {}
+ * Registry.add(A)
+ * const Mixin = x => class extends x {}
+ * Registry.add(Mixin)
+ * const OtherMixin = x => class extends x {}
+ * Registry.add(OtherMixin)
+ * const B = x => class extends x {}
+ * Registry.addByExtending(B, A, [Mixin, OtherMixin])
+ * const ExtendMixin = x => class extends x {}
+ * Registry.extend(Mixin, ExtendMixin)
+ * ```
+ *
+ * In the above, after `Registry.freeze()`,
+ * `|B| => B -> OtherMixin -> ExtendMixin -> Mixin -> A`
+ */
+ class ClassRegistry {
+ constructor() {
+ // base name map
+ this.baseNameMap = {};
+ // Object that maps `baseClass` to the class implementation extended in-place.
+ this.includedMap = new Map();
+ // Object that maps `baseClassCB` to the array of callbacks to generate the extended class.
+ this.extendedCBMap = new Map();
+ // Object that maps `baseClassCB` extended class to the `baseClass` of its super in the includedMap.
+ this.extendedSuperMap = new Map();
+ // For faster access, we can `freeze` the registry so that instead of dynamically generating
+ // the extended classes, it is taken from the cache instead.
+ this.cache = new Map();
+ }
+ /**
+ * Add a new class in the Registry.
+ * @param {Function} baseClass `class`
+ */
+ add(baseClass) {
+ this.includedMap.set(baseClass, []);
+ this.baseNameMap[baseClass.name] = baseClass;
+ }
+ /**
+ * Add a new class in the Registry based on other class
+ * in the registry.
+ * @param {Function} baseClassCB `class -> class`
+ * @param {Function} base `class | class -> class`
+ */
+ addByExtending(baseClassCB, base) {
+ this.extendedCBMap.set(baseClassCB, [baseClassCB]);
+ this.extendedSuperMap.set(baseClassCB, base);
+ this.baseNameMap[baseClassCB.name] = baseClassCB;
+ }
+ /**
+ * Extend in-place a class in the registry. E.g.
+ * ```
+ * // Using the following notation:
+ * // * |A| - compiled class in the registry
+ * // * A - class or an extension callback
+ * // * |A| => A2 -> A1 -> A
+ * // - the above means, compiled class A
+ * // points to the class inheritance derived from
+ * // A2(A1(A))
+ *
+ * class A {};
+ * Registry.add(A);
+ * // |A| => A
+ *
+ * let A1 = x => class extends x {};
+ * Registry.extend(A, A1);
+ * // |A| => A1 -> A
+ *
+ * let B = x => class extends x {};
+ * Registry.addByExtending(B, A);
+ * // |B| => B -> |A|
+ * // |B| => B -> A1 -> A
+ *
+ * let B1 = x => class extends x {};
+ * Registry.extend(B);
+ * // |B| => B1 -> B -> |A|
+ *
+ * let C = x => class extends x {};
+ * Registry.addByExtending(C, B);
+ * // |C| => C -> |B|
+ *
+ * let B2 = x => class extends x {};
+ * Registry.extend(B, B2);
+ * // |B| => B2 -> B1 -> B -> |A|
+ *
+ * // Overall:
+ * // |A| => A1 -> A
+ * // |B| => B2 -> B1 -> B -> A1 -> A
+ * // |C| => C -> B2 -> B1 -> B -> A1 -> A
+ * ```
+ * @param {Function} base `class | class -> class`
+ * @param {Function} extensionCB `class -> class`
+ */
+ extend(base, extensionCB) {
+ if (typeof base === 'string') {
+ base = this.baseNameMap[base];
+ }
+ let extensionArray;
+ if (this.includedMap.get(base)) {
+ extensionArray = this.includedMap.get(base);
+ } else if (this.extendedCBMap.get(base)) {
+ extensionArray = this.extendedCBMap.get(base);
+ } else {
+ throw new Error(
+ `'${base.name}' is not in the Registry. Add it to Registry before extending.`
+ );
+ }
+ extensionArray.push(extensionCB);
+ const locOfNewExtension = extensionArray.length - 1;
+ const self = this;
+ const oldCompiled = this.isFrozen ? this.cache.get(base) : null;
+ return {
+ remove() {
+ extensionArray.splice(locOfNewExtension, 1);
+ self._recompute(base, oldCompiled);
+ },
+ compile() {
+ self._recompute(base);
+ }
+ };
+ }
+ _compile(base) {
+ let res;
+ if (this.includedMap.has(base)) {
+ res = this.includedMap.get(base).reduce((acc, ext) => ext(acc), base);
+ } else {
+ const superClass = this.extendedSuperMap.get(base);
+ const extensionCBs = this.extendedCBMap.get(base);
+ res = extensionCBs.reduce((acc, ext) => ext(acc), this._compile(superClass));
+ }
+ Object.defineProperty(res, 'name', { value: base.name });
+ return res;
+ }
+ /**
+ * Return the compiled class (containing all the extensions) of the base class.
+ * @param {Function} base `class | class -> class` function used in adding the class
+ */
+ get(base) {
+ if (typeof base === 'string') {
+ base = this.baseNameMap[base];
+ }
+ if (this.isFrozen) {
+ return this.cache.get(base);
+ }
+ return this._compile(base);
+ }
+ /**
+ * Uses the callbacks registered in the registry to compile the classes.
+ */
+ freeze() {
+ // Step: Compile the `included classes`.
+ for (let [baseClass, extensionCBs] of this.includedMap.entries()) {
+ const compiled = extensionCBs.reduce((acc, ext) => ext(acc), baseClass);
+ this.cache.set(baseClass, compiled);
+ }
+
+ // Step: Compile the `extended classes` based on `included classes`.
+ // Also gather those the are based on `extended classes`.
+ const remaining = [];
+ for (let [baseClassCB, extensionCBArray] of this.extendedCBMap.entries()) {
+ const compiled = this.cache.get(this.extendedSuperMap.get(baseClassCB));
+ if (!compiled) {
+ remaining.push([baseClassCB, extensionCBArray]);
+ continue;
+ }
+ const extendedClass = extensionCBArray.reduce(
+ (acc, extensionCB) => extensionCB(acc),
+ compiled
+ );
+ this.cache.set(baseClassCB, extendedClass);
+ }
+
+ // Step: Compile the `extended classes` based on `extended classes`.
+ for (let [baseClassCB, extensionCBArray] of remaining) {
+ const compiled = this.cache.get(this.extendedSuperMap.get(baseClassCB));
+ const extendedClass = extensionCBArray.reduce(
+ (acc, extensionCB) => extensionCB(acc),
+ compiled
+ );
+ this.cache.set(baseClassCB, extendedClass);
+ }
+
+ // Step: Set the name of the compiled classess
+ for (let [base, compiledClass] of this.cache.entries()) {
+ Object.defineProperty(compiledClass, 'name', { value: base.name });
+ }
+
+ // Step: Set the flag to true;
+ this.isFrozen = true;
+ }
+ _recompute(base, old) {
+ if (typeof base === 'string') {
+ base = this.baseNameMap[base];
+ }
+ return old ? old : this._compile(base);
+ }
+ }
+
+ return ClassRegistry;
+});
diff --git a/addons/point_of_sale/static/src/js/ComponentRegistry.js b/addons/point_of_sale/static/src/js/ComponentRegistry.js
new file mode 100644
index 00000000000..1e820782e15
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ComponentRegistry.js
@@ -0,0 +1,29 @@
+odoo.define('point_of_sale.ComponentRegistry', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ClassRegistry = require('point_of_sale.ClassRegistry');
+
+ class ComponentRegistry extends ClassRegistry {
+ freeze() {
+ super.freeze();
+ // Make sure PosComponent has the compiled classes.
+ // This way, we don't need to explicitly declare that
+ // a set of components is children of another.
+ PosComponent.components = {};
+ for (let [base, compiledClass] of this.cache.entries()) {
+ PosComponent.components[base.name] = compiledClass;
+ }
+ }
+ _recompute(base, old) {
+ const res = super._recompute(base, old);
+ if (typeof base === 'string') {
+ base = this.baseNameMap[base];
+ }
+ PosComponent.components[base.name] = res;
+ return res;
+ }
+ }
+
+ return ComponentRegistry;
+});
diff --git a/addons/point_of_sale/static/src/js/Gui.js b/addons/point_of_sale/static/src/js/Gui.js
new file mode 100644
index 00000000000..7cc795f24fd
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Gui.js
@@ -0,0 +1,60 @@
+odoo.define('point_of_sale.Gui', function (require) {
+ 'use strict';
+
+ /**
+ * This module bridges the data classes (such as those defined in
+ * models.js) to the view (owl.Component) but not vice versa.
+ *
+ * The idea is to be able to perform side-effects to the user interface
+ * during calculation. Think of console.log during times we want to see
+ * the result of calculations. This is no different, except that instead
+ * of printing something in the console, we access a method in the user
+ * interface then the user interface reacts, e.g. calling `showPopup`.
+ *
+ * This however can be dangerous to the user interface as it can be possible
+ * that a rendered component is destroyed during the calculation. Because of
+ * this, we are going to limit external ui controls to those safe ones to
+ * use such as:
+ * - `showPopup`
+ * - `showTempScreen`
+ *
+ * IMPROVEMENT: After all, this Gui layer seems to be a good abstraction because
+ * there is a complete decoupling between data and view despite the data being
+ * able to use selected functionalities in the view layer. More formalized
+ * implementation is welcome.
+ */
+
+ const config = {};
+
+ /**
+ * Call this when the user interface is ready. Provide the component
+ * that will be used to control the ui.
+ * @param {owl.component} component component having the ui methods.
+ */
+ const configureGui = ({ component }) => {
+ config.component = component;
+ config.availableMethods = new Set([
+ 'showPopup',
+ 'showTempScreen',
+ 'playSound',
+ 'setSyncStatus',
+ ]);
+ };
+
+ /**
+ * Import this and consume like so: `Gui.showPopup(<PopupName>, <props>)`.
+ * Like you would call `showPopup` in a component.
+ */
+ const Gui = new Proxy(config, {
+ get(target, key) {
+ const { component, availableMethods } = target;
+ if (!component) throw new Error(`Call 'configureGui' before using Gui.`);
+ const isMounted = component.__owl__.isMounted;
+ if (availableMethods.has(key) && isMounted) {
+ return component[key].bind(component);
+ }
+ },
+ });
+
+ return { configureGui, Gui };
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/Draggable.js b/addons/point_of_sale/static/src/js/Misc/Draggable.js
new file mode 100644
index 00000000000..0c5f2556524
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/Draggable.js
@@ -0,0 +1,143 @@
+odoo.define('point_of_sale.Draggable', function(require) {
+ 'use strict';
+
+ const { useExternalListener } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * Wrap an element or a component with { position: absolute } to make it
+ * draggable around the limitArea or the nearest positioned ancestor.
+ *
+ * e.g.
+ * ```
+ * <div class="limit-area">
+ * <Draggable limitArea="'.limit-area'">
+ * <div class="popup">
+ * <header class="drag-handle"></header>
+ * </div>
+ * <div class="popup body"></div>
+ * </Draggable>
+ * </div>
+ * ```
+ *
+ * In the above snippet, if the popup div is { position: absolute },
+ * then it becomes draggable around the .limit-area element if it is dragged
+ * thru its Header -- because of the .drag-handle element.
+ *
+ * @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }`
+ */
+ class Draggable extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.isDragging = false;
+ this.dx = 0;
+ this.dy = 0;
+ // drag with mouse
+ useExternalListener(document, 'mousemove', this.move);
+ useExternalListener(document, 'mouseup', this.endDrag);
+ // drag with touch
+ useExternalListener(document, 'touchmove', this.move);
+ useExternalListener(document, 'touchend', this.endDrag);
+
+ useListener('mousedown', '.drag-handle', this.startDrag);
+ useListener('touchstart', '.drag-handle', this.startDrag);
+ }
+ mounted() {
+ this.limitArea = this.props.limitArea
+ ? document.querySelector(this.props.limitArea)
+ : this.el.offsetParent;
+ this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect();
+ if (this.limitArea === this.el.offsetParent) {
+ this.limitLeft = 0;
+ this.limitTop = 0;
+ this.limitRight = this.limitAreaBoundingRect.width;
+ this.limitBottom = this.limitAreaBoundingRect.height;
+ } else {
+ this.limitLeft = -this.el.offsetParent.offsetLeft;
+ this.limitTop = -this.el.offsetParent.offsetTop;
+ this.limitRight =
+ this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft;
+ this.limitBottom =
+ this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop;
+ }
+ this.limitAreaWidth = this.limitAreaBoundingRect.width;
+ this.limitAreaHeight = this.limitAreaBoundingRect.height;
+
+ // absolutely position the element then remove the transform.
+ const elBoundingRect = this.el.getBoundingClientRect();
+ this.el.style.top = `${elBoundingRect.top}px`;
+ this.el.style.left = `${elBoundingRect.left}px`;
+ this.el.style.transform = 'none';
+ }
+ startDrag(event) {
+ let realEvent;
+ if (event instanceof CustomEvent) {
+ realEvent = event.detail;
+ } else {
+ realEvent = event;
+ }
+ const { x, y } = this._getEventLoc(realEvent);
+ this.isDragging = true;
+ this.dx = this.el.offsetLeft - x;
+ this.dy = this.el.offsetTop - y;
+ event.stopPropagation();
+ }
+ move(event) {
+ if (this.isDragging) {
+ const { x: pointerX, y: pointerY } = this._getEventLoc(event);
+ const posLeft = this._getPosLeft(pointerX, this.dx);
+ const posTop = this._getPosTop(pointerY, this.dy);
+ this.el.style.left = `${posLeft}px`;
+ this.el.style.top = `${posTop}px`;
+ }
+ event.preventDefault();
+ }
+ endDrag() {
+ if (this.isDragging) {
+ this.isDragging = false;
+ this.trigger('drag-end', {
+ loc: { top: this.el.offsetTop, left: this.el.offsetLeft },
+ });
+ }
+ }
+ _getEventLoc(event) {
+ let coordX, coordY;
+ if (event.touches && event.touches[0]) {
+ coordX = event.touches[0].clientX;
+ coordY = event.touches[0].clientY;
+ } else {
+ coordX = event.clientX;
+ coordY = event.clientY;
+ }
+ return {
+ x: coordX,
+ y: coordY,
+ };
+ }
+ _getPosLeft(pointerX, dx) {
+ const posLeft = pointerX + dx;
+ if (posLeft < this.limitLeft) {
+ return this.limitLeft;
+ } else if (posLeft > this.limitRight - this.el.offsetWidth) {
+ return this.limitRight - this.el.offsetWidth;
+ }
+ return posLeft;
+ }
+ _getPosTop(pointerY, dy) {
+ const posTop = pointerY + dy;
+ if (posTop < this.limitTop) {
+ return this.limitTop;
+ } else if (posTop > this.limitBottom - this.el.offsetHeight) {
+ return this.limitBottom - this.el.offsetHeight;
+ }
+ return posTop;
+ }
+ }
+ Draggable.template = 'Draggable';
+
+ Registries.Component.add(Draggable);
+
+ return Draggable;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/NotificationSound.js b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js
new file mode 100644
index 00000000000..540e84f1767
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js
@@ -0,0 +1,19 @@
+odoo.define('point_of_sale.NotificationSound', function (require) {
+ 'use strict';
+
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class NotificationSound extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('ended', () => (this.props.sound.src = null));
+ }
+ }
+ NotificationSound.template = 'NotificationSound';
+
+ Registries.Component.add(NotificationSound);
+
+ return NotificationSound;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js
new file mode 100644
index 00000000000..3a358670af5
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js
@@ -0,0 +1,277 @@
+odoo.define('point_of_sale.NumberBuffer', function(require) {
+ 'use strict';
+
+ const { Component, useState } = owl;
+ const { EventBus } = owl.core;
+ const { onMounted, onWillUnmount, useExternalListener } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const { parse } = require('web.field_utils');
+ const { BarcodeEvents } = require('barcodes.BarcodeEvents');
+ const { _t } = require('web.core');
+ const { Gui } = require('point_of_sale.Gui');
+
+ const INPUT_KEYS = new Set(
+ ['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split(''))
+ );
+ const CONTROL_KEYS = new Set(['Enter', 'Esc']);
+ const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]);
+ const getDefaultConfig = () => ({
+ decimalPoint: false,
+ triggerAtEnter: false,
+ triggerAtEsc: false,
+ triggerAtInput: false,
+ nonKeyboardInputEvent: false,
+ useWithBarcode: false,
+ });
+
+ /**
+ * This is a singleton.
+ *
+ * Only one component can `use` the buffer at a time.
+ * This is done by keeping track of each component (and its
+ * corresponding state and config) using a stack (bufferHolderStack).
+ * The component on top of the stack is the one that currently
+ * `holds` the buffer.
+ *
+ * When the current component is unmounted, the top of the stack
+ * is popped and NumberBuffer is set up again for the new component
+ * on top of the stack.
+ *
+ * Usage
+ * =====
+ * - Activate in the construction of root component. `NumberBuffer.activate()`
+ * - Use the buffer in a child component by calling `NumberBuffer.use(<config>)`
+ * in the constructor of the child component.
+ * - The component that `uses` the buffer has access to the following instance
+ * methods of the NumberBuffer:
+ * - get()
+ * - set(val)
+ * - reset()
+ * - getFloat()
+ *
+ * Note
+ * ====
+ * - No need to instantiate as it is a single created before exporting in this module.
+ *
+ * Possible Improvements
+ * =====================
+ * - Relieve the buffer from responsibility of handling `Enter` and other control keys.
+ * - Make the constants (ALLOWED_KEYS, etc.) more configurable.
+ */
+ class NumberBuffer extends EventBus {
+ constructor() {
+ super();
+ this.isReset = false;
+ this.bufferHolderStack = [];
+ }
+ /**
+ * @returns {String} value of the buffer, e.g. '-95.79'
+ */
+ get() {
+ return this.state ? this.state.buffer : null;
+ }
+ /**
+ * Takes a string that is convertible to float, and set it as
+ * value of the buffer. e.g. val = '2.99';
+ *
+ * @param {String} val
+ */
+ set(val) {
+ this.state.buffer = !isNaN(parseFloat(val)) ? val : '';
+ this.trigger('buffer-update', this.state.buffer);
+ }
+ /**
+ * Resets the buffer to empty string.
+ */
+ reset() {
+ this.isReset = true;
+ this.state.buffer = '';
+ this.trigger('buffer-update', this.state.buffer);
+ }
+ /**
+ * @returns {number} float equivalent of the value of buffer
+ */
+ getFloat() {
+ return parse.float(this.get());
+ }
+ /**
+ * Add keyup listener to window via the useExternalListener hook.
+ * When the component calling this is unmounted, the listener is also
+ * removed from window.
+ */
+ activate() {
+ this.defaultDecimalPoint = _t.database.parameters.decimal_point;
+ useExternalListener(window, 'keyup', this._onKeyboardInput.bind(this));
+ }
+ /**
+ * @param {Object} config Use to setup the buffer
+ * @param {String|null} config.decimalPoint The decimal character.
+ * @param {String|null} config.triggerAtEnter Event triggered when 'Enter' key is pressed.
+ * @param {String|null} config.triggerAtInput Event triggered for every accepted input.
+ * @param {String|null} config.nonKeyboardInputEvent Also listen to a non-keyboard input event
+ * that carries a payload of { key }. The key is checked if it is a valid input. If valid,
+ * the number buffer is modified just as it is modified when a keyboard key is pressed.
+ * @param {Boolean} config.useWithBarcode Whether this buffer is used with barcode.
+ */
+ use(config) {
+ this.eventsBuffer = [];
+ const currentComponent = Component.current;
+ config = Object.assign(getDefaultConfig(), config);
+ onMounted(() => {
+ this.bufferHolderStack.push({
+ component: currentComponent,
+ state: config.state ? config.state : useState({ buffer: '' }),
+ config,
+ });
+ this._setUp();
+ });
+ onWillUnmount(() => {
+ this.bufferHolderStack.pop();
+ this._setUp();
+ });
+ // Add listener that accepts non keyboard inputs
+ if (typeof config.nonKeyboardInputEvent === 'string') {
+ useListener(config.nonKeyboardInputEvent, this._onNonKeyboardInput.bind(this));
+ }
+ }
+ get _currentBufferHolder() {
+ return this.bufferHolderStack[this.bufferHolderStack.length - 1];
+ }
+ _setUp() {
+ if (!this._currentBufferHolder) return;
+ const { component, state, config } = this._currentBufferHolder;
+ this.component = component;
+ this.state = state;
+ this.config = config;
+ this.decimalPoint = config.decimalPoint || this.defaultDecimalPoint;
+ this.maxTimeBetweenKeys = this.config.useWithBarcode
+ ? BarcodeEvents.max_time_between_keys_in_ms
+ : 0;
+ }
+ _onKeyboardInput(event) {
+ return this._bufferEvents(this._onInput(event => event.key))(event);
+ }
+ _onNonKeyboardInput(event) {
+ return this._bufferEvents(this._onInput(event => event.detail.key))(event);
+ }
+ _bufferEvents(handler) {
+ return event => {
+ if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return;
+ clearTimeout(this._timeout);
+ this.eventsBuffer.push(event);
+ this._timeout = setTimeout(handler, this.maxTimeBetweenKeys);
+ };
+ }
+ _onInput(keyAccessor) {
+ return () => {
+ if (this.eventsBuffer.length <= 2) {
+ // Check first the buffer if its contents are all valid
+ // number input.
+ for (let event of this.eventsBuffer) {
+ if (!ALLOWED_KEYS.has(keyAccessor(event))) {
+ this.eventsBuffer = [];
+ return;
+ }
+ }
+ // At this point, all the events in buffer
+ // contains number input. It's now okay to handle
+ // each input.
+ for (let event of this.eventsBuffer) {
+ this._handleInput(keyAccessor(event));
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ this.eventsBuffer = [];
+ };
+ }
+ _handleInput(key) {
+ if (key === 'Enter' && this.config.triggerAtEnter) {
+ this.component.trigger(this.config.triggerAtEnter, this.state);
+ } else if (key === 'Esc' && this.config.triggerAtEsc) {
+ this.component.trigger(this.config.triggerAtEsc, this.state);
+ } else if (INPUT_KEYS.has(key)) {
+ this._updateBuffer(key);
+ if (this.config.triggerAtInput)
+ this.component.trigger(this.config.triggerAtInput, { buffer: this.state.buffer, key });
+ }
+ }
+ /**
+ * Updates the current buffer state using the given input.
+ * @param {String} input valid input
+ */
+ _updateBuffer(input) {
+ const isEmpty = val => {
+ return val === '' || val === null;
+ };
+ if (input === undefined || input === null) return;
+ let isFirstInput = isEmpty(this.state.buffer);
+ if (input === this.decimalPoint) {
+ if (isFirstInput) {
+ this.state.buffer = '0.';
+ } else if (!this.state.buffer.length || this.state.buffer === '-') {
+ this.state.buffer += '0.';
+ } else if (this.state.buffer.indexOf(this.decimalPoint) < 0) {
+ this.state.buffer = this.state.buffer + this.decimalPoint;
+ }
+ } else if (input === 'Delete') {
+ if (this.isReset) {
+ this.state.buffer = '';
+ this.isReset = false;
+ return;
+ }
+ this.state.buffer = isEmpty(this.state.buffer) ? null : '';
+ } else if (input === 'Backspace') {
+ if (this.isReset) {
+ this.state.buffer = '';
+ this.isReset = false;
+ return;
+ }
+ const buffer = this.state.buffer;
+ if (isEmpty(buffer)) {
+ this.state.buffer = null;
+ } else {
+ const nCharToRemove = buffer[buffer.length - 1] === this.decimalPoint ? 2 : 1;
+ this.state.buffer = buffer.substring(0, buffer.length - nCharToRemove);
+ }
+ } else if (input === '+') {
+ if (this.state.buffer[0] === '-') {
+ this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length);
+ }
+ } else if (input === '-') {
+ if (isFirstInput) {
+ this.state.buffer = '-0';
+ } else if (this.state.buffer[0] === '-') {
+ this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length);
+ } else {
+ this.state.buffer = '-' + this.state.buffer;
+ }
+ } else if (input[0] === '+' && !isNaN(parseFloat(input))) {
+ // when input is like '+10', '+50', etc
+ const inputValue = parse.float(input.slice(1));
+ const currentBufferValue = this.state.buffer ? parse.float(this.state.buffer) : 0;
+ this.state.buffer = this.component.env.pos.formatFixed(
+ inputValue + currentBufferValue
+ );
+ } else if (!isNaN(parseInt(input, 10))) {
+ if (isFirstInput) {
+ this.state.buffer = '' + input;
+ } else if (this.state.buffer.length > 12) {
+ Gui.playSound('bell');
+ } else {
+ this.state.buffer += input;
+ }
+ }
+ if (this.state.buffer === '-') {
+ this.state.buffer = '';
+ }
+ // once an input is accepted and updated the buffer,
+ // the buffer should not be in reset state anymore.
+ this.isReset = false;
+
+ this.trigger('buffer-update', this.state.buffer);
+ }
+ }
+
+ return new NumberBuffer();
+});
diff --git a/addons/point_of_sale/static/src/js/PopupControllerMixin.js b/addons/point_of_sale/static/src/js/PopupControllerMixin.js
new file mode 100644
index 00000000000..446a514aa46
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/PopupControllerMixin.js
@@ -0,0 +1,44 @@
+odoo.define('point_of_sale.PopupControllerMixin', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const { useListener } = require('web.custom_hooks');
+
+ /**
+ * Allows the component declared with this mixin the ability show popup dynamically,
+ * provided the following:
+ * 1. The following element is declared in the template. It is where the Popup will be rendered.
+ * `<t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />`
+ * 2. The component should trigger `show-popup` event to show the popup and `close-popup` event
+ * to close. In PosComponent, `showPopup` is conveniently declared to satisfy this requirement.
+ * @param {Function} x class definition to mix with during extension
+ */
+ const PopupControllerMixin = x =>
+ class extends x {
+ constructor() {
+ super(...arguments);
+ useListener('show-popup', this.__showPopup);
+ useListener('close-popup', this.__closePopup);
+
+ this.popup = useState({ isShown: false, name: null, component: null });
+ this.popupProps = {}; // We want to avoid making the props to become Proxy!
+ }
+ __showPopup(event) {
+ const { name, props, resolve } = event.detail;
+ const popupConstructor = this.constructor.components[name];
+ if (popupConstructor.dontShow) {
+ resolve();
+ return;
+ }
+ this.popup.isShown = true;
+ this.popup.name = name;
+ this.popup.component = popupConstructor;
+ this.popupProps = Object.assign({}, props, { resolve });
+ }
+ __closePopup() {
+ this.popup.isShown = false;
+ }
+ };
+
+ return PopupControllerMixin;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js
new file mode 100644
index 00000000000..6cdd6a04bab
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js
@@ -0,0 +1,60 @@
+odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) {
+ 'use strict';
+
+ const { useExternalListener } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+
+ /**
+ * Implement this abstract class by extending it like so:
+ * ```js
+ * class ConcretePopup extends AbstractAwaitablePopup {
+ * async getPayload() {
+ * return 'result';
+ * }
+ * }
+ * ConcretePopup.template = owl.tags.xml`
+ * <div>
+ * <button t-on-click="confirm">Okay</button>
+ * <button t-on-click="cancel">Cancel</button>
+ * </div>
+ * `
+ * ```
+ *
+ * The concrete popup can now be instantiated and be awaited for
+ * the user's response like so:
+ * ```js
+ * const { confirmed, payload } = await this.showPopup('ConcretePopup');
+ * // based on the implementation above,
+ * // if confirmed, payload = 'result'
+ * // otherwise, payload = null
+ * ```
+ */
+ class AbstractAwaitablePopup extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useExternalListener(window, 'keyup', this._cancelAtEscape);
+ }
+ async confirm() {
+ this.props.resolve({ confirmed: true, payload: await this.getPayload() });
+ this.trigger('close-popup');
+ }
+ cancel() {
+ this.props.resolve({ confirmed: false, payload: null });
+ this.trigger('close-popup');
+ }
+ _cancelAtEscape(event) {
+ if (event.key === 'Escape') {
+ this.cancel();
+ }
+ }
+ /**
+ * Override this in the concrete popup implementation to set the
+ * payload when the popup is confirmed.
+ */
+ async getPayload() {
+ return null;
+ }
+ }
+
+ return AbstractAwaitablePopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js
new file mode 100644
index 00000000000..e22c1aaa159
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js
@@ -0,0 +1,20 @@
+odoo.define('point_of_sale.ConfirmPopup', function(require) {
+ 'use strict';
+
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly ConfirmPopupWidget
+ class ConfirmPopup extends AbstractAwaitablePopup {}
+ ConfirmPopup.template = 'ConfirmPopup';
+ ConfirmPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: 'Confirm ?',
+ body: '',
+ };
+
+ Registries.Component.add(ConfirmPopup);
+
+ return ConfirmPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/EditListInput.js b/addons/point_of_sale/static/src/js/Popups/EditListInput.js
new file mode 100644
index 00000000000..09b39f212c9
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/EditListInput.js
@@ -0,0 +1,19 @@
+odoo.define('point_of_sale.EditListInput', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class EditListInput extends PosComponent {
+ onKeyup(event) {
+ if (event.key === "Enter" && event.target.value.trim() !== '') {
+ this.trigger('create-new-item');
+ }
+ }
+ }
+ EditListInput.template = 'EditListInput';
+
+ Registries.Component.add(EditListInput);
+
+ return EditListInput;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/EditListPopup.js b/addons/point_of_sale/static/src/js/Popups/EditListPopup.js
new file mode 100644
index 00000000000..ac4b262d668
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/EditListPopup.js
@@ -0,0 +1,105 @@
+odoo.define('point_of_sale.EditListPopup', function(require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+ const { useAutoFocusToLast } = require('point_of_sale.custom_hooks');
+
+ /**
+ * Given a array of { id, text }, we show the user this popup to be able to modify this given array.
+ * (used to replace PackLotLinePopupWidget)
+ *
+ * The expected return of showPopup when this popup is used is an array of { _id, [id], text }.
+ * - _id is the assigned unique identifier for each item.
+ * - id is the original id. if not provided, then it means that the item is new.
+ * - text is the modified/unmodified text.
+ *
+ * Example:
+ *
+ * ```
+ * -- perhaps inside a click handler --
+ * // gather the items to edit
+ * const names = [{ id: 1, text: 'Joseph'}, { id: 2, text: 'Kaykay' }];
+ *
+ * // supply the items to the popup and wait for user's response
+ * // when user pressed `confirm` in the popup, the changes he made will be returned by the showPopup function.
+ * const { confirmed, payload: newNames } = await this.showPopup('EditListPopup', {
+ * title: "Can you confirm this item?",
+ * array: names })
+ *
+ * // we then consume the new data. In this example, it is only logged.
+ * if (confirmed) {
+ * console.log(newNames);
+ * // the above might log the following:
+ * // [{ _id: 1, id: 1, text: 'Joseph Caburnay' }, { _id: 2, id: 2, 'Kaykay' }, { _id: 3, 'James' }]
+ * // The result showed that the original item with id=1 was changed to have text 'Joseph Caburnay' from 'Joseph'
+ * // The one with id=2 did not change. And a new item with text='James' is added.
+ * }
+ * ```
+ */
+ class EditListPopup extends AbstractAwaitablePopup {
+ /**
+ * @param {String} title required title of popup
+ * @param {Array} [props.array=[]] the array of { id, text } to be edited or an array of strings
+ * @param {Boolean} [props.isSingleItem=false] true if only allowed to edit single item (the first item)
+ */
+ constructor() {
+ super(...arguments);
+ this._id = 0;
+ this.state = useState({ array: this._initialize(this.props.array) });
+ useAutoFocusToLast();
+ }
+ _nextId() {
+ return this._id++;
+ }
+ _emptyItem() {
+ return {
+ text: '',
+ _id: this._nextId(),
+ };
+ }
+ _initialize(array) {
+ // If no array is provided, we initialize with one empty item.
+ if (array.length === 0) return [this._emptyItem()];
+ // Put _id for each item. It will serve as unique identifier of each item.
+ return array.map((item) => Object.assign({}, { _id: this._nextId() }, typeof item === 'object'? item: { 'text': item}));
+ }
+ removeItem(event) {
+ const itemToRemove = event.detail;
+ this.state.array.splice(
+ this.state.array.findIndex(item => item._id == itemToRemove._id),
+ 1
+ );
+ // We keep a minimum of one empty item in the popup.
+ if (this.state.array.length === 0) {
+ this.state.array.push(this._emptyItem());
+ }
+ }
+ createNewItem() {
+ if (this.props.isSingleItem) return;
+ this.state.array.push(this._emptyItem());
+ }
+ /**
+ * @override
+ */
+ getPayload() {
+ return {
+ newArray: this.state.array
+ .filter((item) => item.text.trim() !== '')
+ .map((item) => Object.assign({}, item)),
+ };
+ }
+ }
+ EditListPopup.template = 'EditListPopup';
+ EditListPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ array: [],
+ isSingleItem: false,
+ };
+
+ Registries.Component.add(EditListPopup);
+
+ return EditListPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js
new file mode 100644
index 00000000000..8cf11c40207
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js
@@ -0,0 +1,26 @@
+odoo.define('point_of_sale.ErrorBarcodePopup', function(require) {
+ 'use strict';
+
+ const ErrorPopup = require('point_of_sale.ErrorPopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly ErrorBarcodePopupWidget
+ class ErrorBarcodePopup extends ErrorPopup {
+ get translatedMessage() {
+ return this.env._t(this.props.message);
+ }
+ }
+ ErrorBarcodePopup.template = 'ErrorBarcodePopup';
+ ErrorBarcodePopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: 'Error',
+ body: '',
+ message:
+ 'The Point of Sale could not find any product, client, employee or action associated with the scanned barcode.',
+ };
+
+ Registries.Component.add(ErrorBarcodePopup);
+
+ return ErrorBarcodePopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js
new file mode 100644
index 00000000000..865779c475e
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js
@@ -0,0 +1,24 @@
+odoo.define('point_of_sale.ErrorPopup', function(require) {
+ 'use strict';
+
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly ErrorPopupWidget
+ class ErrorPopup extends AbstractAwaitablePopup {
+ mounted() {
+ this.playSound('error');
+ }
+ }
+ ErrorPopup.template = 'ErrorPopup';
+ ErrorPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: 'Error',
+ body: '',
+ };
+
+ Registries.Component.add(ErrorPopup);
+
+ return ErrorPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js
new file mode 100644
index 00000000000..1af25e42180
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js
@@ -0,0 +1,44 @@
+odoo.define('point_of_sale.ErrorTracebackPopup', function(require) {
+ 'use strict';
+
+ const ErrorPopup = require('point_of_sale.ErrorPopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly ErrorTracebackPopupWidget
+ class ErrorTracebackPopup extends ErrorPopup {
+ get tracebackUrl() {
+ const blob = new Blob([this.props.body]);
+ const URL = window.URL || window.webkitURL;
+ return URL.createObjectURL(blob);
+ }
+ get tracebackFilename() {
+ return `${this.env._t('error')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.txt`;
+ }
+ emailTraceback() {
+ const address = this.env.pos.company.email;
+ const subject = this.env._t('IMPORTANT: Bug Report From Odoo Point Of Sale');
+ window.open(
+ 'mailto:' +
+ address +
+ '?subject=' +
+ (subject ? window.encodeURIComponent(subject) : '') +
+ '&body=' +
+ (this.props.body ? window.encodeURIComponent(this.props.body) : '')
+ );
+ }
+ }
+ ErrorTracebackPopup.template = 'ErrorTracebackPopup';
+ ErrorTracebackPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: 'Error with Traceback',
+ body: '',
+ exitButtonIsShown: false,
+ exitButtonText: 'Exit Pos',
+ exitButtonTrigger: 'close-pos'
+ };
+
+ Registries.Component.add(ErrorTracebackPopup);
+
+ return ErrorTracebackPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/NumberPopup.js b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js
new file mode 100644
index 00000000000..385007d5812
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js
@@ -0,0 +1,71 @@
+odoo.define('point_of_sale.NumberPopup', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly NumberPopupWidget
+ class NumberPopup extends AbstractAwaitablePopup {
+ /**
+ * @param {Object} props
+ * @param {Boolean} props.isPassword Show password popup.
+ * @param {number|null} props.startingValue Starting value of the popup.
+ *
+ * Resolve to { confirmed, payload } when used with showPopup method.
+ * @confirmed {Boolean}
+ * @payload {String}
+ */
+ constructor() {
+ super(...arguments);
+ useListener('accept-input', this.confirm);
+ useListener('close-this-popup', this.cancel);
+ let startingBuffer = '';
+ if (typeof this.props.startingValue === 'number' && this.props.startingValue > 0) {
+ startingBuffer = this.props.startingValue.toString();
+ }
+ this.state = useState({ buffer: startingBuffer });
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ triggerAtEnter: 'accept-input',
+ triggerAtEscape: 'close-this-popup',
+ state: this.state,
+ });
+ }
+ get decimalSeparator() {
+ return this.env._t.database.parameters.decimal_point;
+ }
+ get inputBuffer() {
+ if (this.state.buffer === null) {
+ return '';
+ }
+ if (this.props.isPassword) {
+ return this.state.buffer.replace(/./g, '•');
+ } else {
+ return this.state.buffer;
+ }
+ }
+ sendInput(key) {
+ this.trigger('numpad-click-input', { key });
+ }
+ getPayload() {
+ return NumberBuffer.get();
+ }
+ }
+ NumberPopup.template = 'NumberPopup';
+ NumberPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: 'Confirm ?',
+ body: '',
+ cheap: false,
+ startingValue: null,
+ isPassword: false,
+ };
+
+ Registries.Component.add(NumberPopup);
+
+ return NumberPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js b/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js
new file mode 100644
index 00000000000..147ed7c435a
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js
@@ -0,0 +1,29 @@
+odoo.define('point_of_sale.OfflineErrorPopup', function(require) {
+ 'use strict';
+
+ const ErrorPopup = require('point_of_sale.ErrorPopup');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * This is a special kind of error popup as it introduces
+ * an option to not show it again.
+ */
+ class OfflineErrorPopup extends ErrorPopup {
+ dontShowAgain() {
+ this.constructor.dontShow = true;
+ this.cancel();
+ }
+ }
+ OfflineErrorPopup.template = 'OfflineErrorPopup';
+ OfflineErrorPopup.dontShow = false;
+ OfflineErrorPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: 'Offline Error',
+ body: 'Either the server is inaccessible or browser is not connected online.',
+ };
+
+ Registries.Component.add(OfflineErrorPopup);
+
+ return OfflineErrorPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js b/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js
new file mode 100644
index 00000000000..c2c352917e7
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js
@@ -0,0 +1,27 @@
+odoo.define('point_of_sale.OrderImportPopup', function(require) {
+ 'use strict';
+
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly OrderImportPopupWidget
+ class OrderImportPopup extends AbstractAwaitablePopup {
+ get unpaidSkipped() {
+ return (
+ (this.props.report.unpaid_skipped_existing || 0) +
+ (this.props.report.unpaid_skipped_session || 0)
+ );
+ }
+ getPayload() {}
+ }
+ OrderImportPopup.template = 'OrderImportPopup';
+ OrderImportPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ body: '',
+ };
+
+ Registries.Component.add(OrderImportPopup);
+
+ return OrderImportPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js
new file mode 100644
index 00000000000..8e9f7456aea
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js
@@ -0,0 +1,63 @@
+odoo.define('point_of_sale.SelectionPopup', function(require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly SelectionPopupWidget
+ class SelectionPopup extends AbstractAwaitablePopup {
+ /**
+ * Value of the `item` key of the selected element in the Selection
+ * Array is the payload of this popup.
+ *
+ * @param {Object} props
+ * @param {String} [props.confirmText='Confirm']
+ * @param {String} [props.cancelText='Cancel']
+ * @param {String} [props.title='Select']
+ * @param {String} [props.body='']
+ * @param {Array<Selection>} [props.list=[]]
+ * Selection {
+ * id: integer,
+ * label: string,
+ * isSelected: boolean,
+ * item: any,
+ * }
+ */
+ constructor() {
+ super(...arguments);
+ this.list = useState([...this.props.list]);
+ }
+ selectItem(itemId) {
+ for (let item of this.list) {
+ if (item.id === itemId) {
+ item.isSelected = true;
+ } else {
+ item.isSelected = false;
+ }
+ }
+ this.confirm();
+ }
+ /**
+ * We send as payload of the response the selected item.
+ *
+ * @override
+ */
+ getPayload() {
+ const selected = this.props.list.find(item => item.isSelected);
+ return selected && selected.item;
+ }
+ }
+ SelectionPopup.template = 'SelectionPopup';
+ SelectionPopup.defaultProps = {
+ confirmText: 'Confirm',
+ cancelText: 'Cancel',
+ title: 'Select',
+ body: '',
+ list: [],
+ };
+
+ Registries.Component.add(SelectionPopup);
+
+ return SelectionPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js b/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js
new file mode 100644
index 00000000000..1f2735f671d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.TextAreaPopup', function(require) {
+ 'use strict';
+
+ const { useState, useRef } = owl.hooks;
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly TextAreaPopupWidget
+ // IMPROVEMENT: This code is very similar to TextInputPopup.
+ // Combining them would reduce the code.
+ class TextAreaPopup extends AbstractAwaitablePopup {
+ /**
+ * @param {Object} props
+ * @param {string} props.startingValue
+ */
+ constructor() {
+ super(...arguments);
+ this.state = useState({ inputValue: this.props.startingValue });
+ this.inputRef = useRef('input');
+ }
+ mounted() {
+ this.inputRef.el.focus();
+ }
+ getPayload() {
+ return this.state.inputValue;
+ }
+ }
+ TextAreaPopup.template = 'TextAreaPopup';
+ TextAreaPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: '',
+ body: '',
+ };
+
+ Registries.Component.add(TextAreaPopup);
+
+ return TextAreaPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js b/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js
new file mode 100644
index 00000000000..4a0612d21fe
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js
@@ -0,0 +1,34 @@
+odoo.define('point_of_sale.TextInputPopup', function(require) {
+ 'use strict';
+
+ const { useState, useRef } = owl.hooks;
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly TextInputPopupWidget
+ class TextInputPopup extends AbstractAwaitablePopup {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ inputValue: this.props.startingValue });
+ this.inputRef = useRef('input');
+ }
+ mounted() {
+ this.inputRef.el.focus();
+ }
+ getPayload() {
+ return this.state.inputValue;
+ }
+ }
+ TextInputPopup.template = 'TextInputPopup';
+ TextInputPopup.defaultProps = {
+ confirmText: 'Ok',
+ cancelText: 'Cancel',
+ title: '',
+ body: '',
+ startingValue: '',
+ };
+
+ Registries.Component.add(TextInputPopup);
+
+ return TextInputPopup;
+});
diff --git a/addons/point_of_sale/static/src/js/PosComponent.js b/addons/point_of_sale/static/src/js/PosComponent.js
new file mode 100644
index 00000000000..ae2873ba828
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/PosComponent.js
@@ -0,0 +1,59 @@
+odoo.define('point_of_sale.PosComponent', function (require) {
+ 'use strict';
+
+ const { Component } = owl;
+
+ class PosComponent extends Component {
+ /**
+ * This function is available to all Components that inherit this class.
+ * The goal of this function is to show an awaitable dialog (popup) that
+ * returns a response after user interaction. See the following for quick
+ * demonstration:
+ *
+ * ```
+ * async getUserName() {
+ * const userResponse = await this.showPopup(
+ * 'TextInputPopup',
+ * { title: 'What is your name?' }
+ * );
+ * // at this point, the TextInputPopup is displayed. Depending on how the popup is defined,
+ * // say the input contains the name, the result of the interaction with the user is
+ * // saved in `userResponse`.
+ * console.log(userResponse); // logs { confirmed: true, payload: <name> }
+ * }
+ * ```
+ *
+ * @param {String} name Name of the popup component
+ * @param {Object} props Object that will be used to render to popup
+ */
+ showPopup(name, props) {
+ return new Promise((resolve) => {
+ this.trigger('show-popup', { name, props, resolve });
+ });
+ }
+ showTempScreen(name, props) {
+ return new Promise((resolve) => {
+ this.trigger('show-temp-screen', { name, props, resolve });
+ });
+ }
+ showScreen(name, props) {
+ this.trigger('show-main-screen', { name, props });
+ }
+ /**
+ * @param {String} name 'bell' | 'error'
+ */
+ playSound(name) {
+ this.trigger('play-sound', name);
+ }
+ /**
+ * Control the SyncNotification component.
+ * @param {String} status 'connected' | 'connecting' | 'disconnected' | 'error'
+ * @param {String} pending number of pending orders to sync
+ */
+ setSyncStatus(status, pending) {
+ this.trigger('set-sync-status', { status, pending });
+ }
+ }
+
+ return PosComponent;
+});
diff --git a/addons/point_of_sale/static/src/js/Registries.js b/addons/point_of_sale/static/src/js/Registries.js
new file mode 100644
index 00000000000..e95817ab129
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Registries.js
@@ -0,0 +1,11 @@
+odoo.define('point_of_sale.Registries', function(require) {
+ 'use strict';
+
+ /**
+ * This definition contains all the instances of ClassRegistry.
+ */
+
+ const ComponentRegistry = require('point_of_sale.ComponentRegistry');
+
+ return { Component: new ComponentRegistry() };
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js
new file mode 100644
index 00000000000..face1da67ee
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js
@@ -0,0 +1,22 @@
+odoo.define('point_of_sale.ClientDetails', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ClientDetails extends PosComponent {
+ get partnerImageUrl() {
+ const partner = this.props.partner;
+ if (partner) {
+ return `/web/image?model=res.partner&id=${partner.id}&field=image_128&write_date=${partner.write_date}&unique=1`;
+ } else {
+ return false;
+ }
+ }
+ }
+ ClientDetails.template = 'ClientDetails';
+
+ Registries.Component.add(ClientDetails);
+
+ return ClientDetails;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js
new file mode 100644
index 00000000000..e8ebf925504
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js
@@ -0,0 +1,112 @@
+odoo.define('point_of_sale.ClientDetailsEdit', function(require) {
+ 'use strict';
+
+ const { getDataURLFromFile } = require('web.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ClientDetailsEdit extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.intFields = ['country_id', 'state_id', 'property_product_pricelist'];
+ this.changes = {};
+ }
+ get partnerImageUrl() {
+ // We prioritize image_1920 in the `changes` field because we want
+ // to show the uploaded image without fetching new data from the server.
+ const partner = this.props.partner;
+ if (this.changes.image_1920) {
+ return this.changes.image_1920;
+ } else if (partner.id) {
+ return `/web/image?model=res.partner&id=${partner.id}&field=image_128&write_date=${partner.write_date}&unique=1`;
+ } else {
+ return false;
+ }
+ }
+ /**
+ * Save to field `changes` all input changes from the form fields.
+ */
+ captureChange(event) {
+ this.changes[event.target.name] = event.target.value;
+ }
+ saveChanges() {
+ let processedChanges = {};
+ for (let [key, value] of Object.entries(this.changes)) {
+ if (this.intFields.includes(key)) {
+ processedChanges[key] = parseInt(value) || false;
+ } else {
+ processedChanges[key] = value;
+ }
+ }
+ processedChanges.id = this.props.partner.id || false;
+ this.trigger('save-changes', { processedChanges });
+ }
+ async uploadImage(event) {
+ const file = event.target.files[0];
+ if (!file.type.match(/image.*/)) {
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Unsupported File Format'),
+ body: this.env._t(
+ 'Only web-compatible Image formats such as .png or .jpeg are supported.'
+ ),
+ });
+ } else {
+ const imageUrl = await getDataURLFromFile(file);
+ const loadedImage = await this._loadImage(imageUrl);
+ if (loadedImage) {
+ const resizedImage = await this._resizeImage(loadedImage, 800, 600);
+ this.changes.image_1920 = resizedImage.toDataURL();
+ // Rerender to reflect the changes in the screen
+ this.render();
+ }
+ }
+ }
+ _resizeImage(img, maxwidth, maxheight) {
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+ var ratio = 1;
+
+ if (img.width > maxwidth) {
+ ratio = maxwidth / img.width;
+ }
+ if (img.height * ratio > maxheight) {
+ ratio = maxheight / img.height;
+ }
+ var width = Math.floor(img.width * ratio);
+ var height = Math.floor(img.height * ratio);
+
+ canvas.width = width;
+ canvas.height = height;
+ ctx.drawImage(img, 0, 0, width, height);
+ return canvas;
+ }
+ /**
+ * Loading image is converted to a Promise to allow await when
+ * loading an image. It resolves to the loaded image if succesful,
+ * else, resolves to false.
+ *
+ * [Source](https://stackoverflow.com/questions/45788934/how-to-turn-this-callback-into-a-promise-using-async-await)
+ */
+ _loadImage(url) {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.addEventListener('load', () => resolve(img));
+ img.addEventListener('error', () => {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Loading Image Error'),
+ body: this.env._t(
+ 'Encountered error when loading image. Please try again.'
+ ),
+ });
+ resolve(false);
+ });
+ img.src = url;
+ });
+ }
+ }
+ ClientDetailsEdit.template = 'ClientDetailsEdit';
+
+ Registries.Component.add(ClientDetailsEdit);
+
+ return ClientDetailsEdit;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js
new file mode 100644
index 00000000000..5fad17e1594
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js
@@ -0,0 +1,21 @@
+odoo.define('point_of_sale.ClientLine', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ClientLine extends PosComponent {
+ get highlight() {
+ if (this.props.partner !== this.props.selectedClient) {
+ return '';
+ } else {
+ return this.props.detailIsShown ? 'highlight' : 'lowlight';
+ }
+ }
+ }
+ ClientLine.template = 'ClientLine';
+
+ Registries.Component.add(ClientLine);
+
+ return ClientLine;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js
new file mode 100644
index 00000000000..c4222bb5b8a
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js
@@ -0,0 +1,167 @@
+odoo.define('point_of_sale.ClientListScreen', function(require) {
+ 'use strict';
+
+ const { debounce } = owl.utils;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * Render this screen using `showTempScreen` to select client.
+ * When the shown screen is confirmed ('Set Customer' or 'Deselect Customer'
+ * button is clicked), the call to `showTempScreen` resolves to the
+ * selected client. E.g.
+ *
+ * ```js
+ * const { confirmed, payload: selectedClient } = await showTempScreen('ClientListScreen');
+ * if (confirmed) {
+ * // do something with the selectedClient
+ * }
+ * ```
+ *
+ * @props client - originally selected client
+ */
+ class ClientListScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ // We are not using useState here because the object
+ // passed to useState converts the object and its contents
+ // to Observer proxy. Not sure of the side-effects of making
+ // a persistent object, such as pos, into owl.Observer. But it
+ // is better to be safe.
+ this.state = {
+ query: null,
+ selectedClient: this.props.client,
+ detailIsShown: Boolean(this.props.client),
+ isEditMode: false,
+ editModeProps: {
+ partner: {
+ country_id: this.env.pos.company.country_id,
+ state_id: this.env.pos.company.state_id,
+ }
+ },
+ };
+ this.updateClientList = debounce(this.updateClientList, 70);
+ }
+
+ // Lifecycle hooks
+ back() {
+ this.props.resolve({ confirmed: false, payload: false });
+ this.trigger('close-temp-screen');
+ }
+ confirm() {
+ this.props.resolve({ confirmed: true, payload: this.state.selectedClient });
+ this.trigger('close-temp-screen');
+ }
+ // Getters
+
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+
+ get clients() {
+ if (this.state.query && this.state.query.trim() !== '') {
+ return this.env.pos.db.search_partner(this.state.query.trim());
+ } else {
+ return this.env.pos.db.get_partners_sorted(1000);
+ }
+ }
+ get isNextButtonVisible() {
+ return this.state.selectedClient ? !this.state.isEditMode : false;
+ }
+ /**
+ * Returns the text and command of the next button.
+ * The command field is used by the clickNext call.
+ */
+ get nextButton() {
+ if (!this.props.client) {
+ return { command: 'set', text: 'Set Customer' };
+ } else if (
+ this.props.client &&
+ this.props.client === this.state.selectedClient
+ ) {
+ return { command: 'deselect', text: 'Deselect Customer' };
+ } else {
+ return { command: 'set', text: 'Change Customer' };
+ }
+ }
+
+ // Methods
+
+ // We declare this event handler as a debounce function in
+ // order to lower its trigger rate.
+ updateClientList(event) {
+ this.state.query = event.target.value;
+ const clients = this.clients;
+ if (event.code === 'Enter' && clients.length === 1) {
+ this.state.selectedClient = clients[0];
+ this.clickNext();
+ } else {
+ this.render();
+ }
+ }
+ clickClient(event) {
+ let partner = event.detail.client;
+ if (this.state.selectedClient === partner) {
+ this.state.detailIsShown = !this.state.detailIsShown;
+ } else {
+ this.state.selectedClient = partner;
+ this.state.detailIsShown = true;
+ }
+ this.deactivateEditMode();
+ }
+ clickNext() {
+ this.state.selectedClient = this.nextButton.command === 'set' ? this.state.selectedClient : null;
+ this.confirm();
+ }
+ activateEditMode(event) {
+ const { isNewClient } = event.detail;
+ this.state.isEditMode = true;
+ this.state.detailIsShown = true;
+ if (!isNewClient) {
+ this.state.editModeProps = {
+ partner: this.state.selectedClient,
+ };
+ }
+ this.render();
+ }
+ deactivateEditMode() {
+ this.state.isEditMode = false;
+ this.state.editModeProps = {
+ partner: {
+ country_id: this.env.pos.company.country_id,
+ state_id: this.env.pos.company.state_id,
+ },
+ };
+ this.render();
+ }
+ async saveChanges(event) {
+ try {
+ let partnerId = await this.rpc({
+ model: 'res.partner',
+ method: 'create_from_ui',
+ args: [event.detail.processedChanges],
+ });
+ await this.env.pos.load_new_partners();
+ this.state.selectedClient = this.env.pos.db.get_partner_by_id(partnerId);
+ this.deactivateEditMode();
+ } catch (error) {
+ if (error.message.code < 0) {
+ await this.showPopup('OfflineErrorPopup', {
+ title: this.env._t('Offline'),
+ body: this.env._t('Unable to save changes.'),
+ });
+ } else {
+ throw error;
+ }
+ }
+ }
+ cancelEdit() {
+ this.deactivateEditMode();
+ }
+ }
+ ClientListScreen.template = 'ClientListScreen';
+
+ Registries.Component.add(ClientListScreen);
+
+ return ClientListScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js
new file mode 100644
index 00000000000..b5dc6a7bd54
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js
@@ -0,0 +1,17 @@
+odoo.define('point_of_sale.PSNumpadInputButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PSNumpadInputButton extends PosComponent {
+ get _class() {
+ return this.props.changeClassTo || 'input-button number-char';
+ }
+ }
+ PSNumpadInputButton.template = 'PSNumpadInputButton';
+
+ Registries.Component.add(PSNumpadInputButton);
+
+ return PSNumpadInputButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js
new file mode 100644
index 00000000000..8e5853d322f
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.PaymentMethodButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentMethodButton extends PosComponent {}
+ PaymentMethodButton.template = 'PaymentMethodButton';
+
+ Registries.Component.add(PaymentMethodButton);
+
+ return PaymentMethodButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js
new file mode 100644
index 00000000000..641fb621f4b
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js
@@ -0,0 +1,422 @@
+odoo.define('point_of_sale.PaymentScreen', function (require) {
+ 'use strict';
+
+ const { parse } = require('web.field_utils');
+ const { is_email } = require('web.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useErrorHandlers } = require('point_of_sale.custom_hooks');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const { useListener } = require('web.custom_hooks');
+ const OrderReceipt = require('point_of_sale.OrderReceipt');
+ const { Printer } = require('point_of_sale.Printer');
+ const Registries = require('point_of_sale.Registries');
+ const { onChangeOrder } = require('point_of_sale.custom_hooks');
+
+ class PaymentScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('delete-payment-line', this.deletePaymentLine);
+ useListener('select-payment-line', this.selectPaymentLine);
+ useListener('new-payment-line', this.addNewPaymentLine);
+ useListener('update-selected-paymentline', this._updateSelectedPaymentline);
+ useListener('send-payment-request', this._sendPaymentRequest);
+ useListener('send-payment-cancel', this._sendPaymentCancel);
+ useListener('send-payment-reverse', this._sendPaymentReverse);
+ useListener('send-force-done', this._sendForceDone);
+ onChangeOrder(this._onPrevOrder, this._onNewOrder);
+ useErrorHandlers();
+ NumberBuffer.use({
+ // The numberBuffer listens to this event to update its state.
+ // Basically means 'update the buffer when this event is triggered'
+ nonKeyboardInputEvent: 'input-from-numpad',
+ // When the buffer is updated, trigger this event.
+ // Note that the component listens to it.
+ triggerAtInput: 'update-selected-paymentline',
+ });
+ this.payment_interface = null;
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get paymentLines() {
+ return this.currentOrder.get_paymentlines();
+ }
+ get selectedPaymentLine() {
+ return this.currentOrder.selected_paymentline;
+ }
+ async selectClient() {
+ // IMPROVEMENT: This code snippet is repeated multiple times.
+ // Maybe it's better to create a function for it.
+ const currentClient = this.currentOrder.get_client();
+ const { confirmed, payload: newClient } = await this.showTempScreen(
+ 'ClientListScreen',
+ { client: currentClient }
+ );
+ if (confirmed) {
+ this.currentOrder.set_client(newClient);
+ this.currentOrder.updatePricelist(newClient);
+ }
+ }
+ addNewPaymentLine({ detail: paymentMethod }) {
+ // original function: click_paymentmethods
+ if (this.currentOrder.electronic_payment_in_progress()) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Error'),
+ body: this.env._t('There is already an electronic payment in progress.'),
+ });
+ return false;
+ } else {
+ this.currentOrder.add_paymentline(paymentMethod);
+ NumberBuffer.reset();
+ this.payment_interface = paymentMethod.payment_terminal;
+ if (this.payment_interface) {
+ this.currentOrder.selected_paymentline.set_payment_status('pending');
+ }
+ return true;
+ }
+ }
+ _updateSelectedPaymentline() {
+ if (this.paymentLines.every((line) => line.paid)) {
+ this.currentOrder.add_paymentline(this.env.pos.payment_methods[0]);
+ }
+ if (!this.selectedPaymentLine) return; // do nothing if no selected payment line
+ // disable changing amount on paymentlines with running or done payments on a payment terminal
+ if (
+ this.payment_interface &&
+ !['pending', 'retry'].includes(this.selectedPaymentLine.get_payment_status())
+ ) {
+ return;
+ }
+ if (NumberBuffer.get() === null) {
+ this.deletePaymentLine({ detail: { cid: this.selectedPaymentLine.cid } });
+ } else {
+ this.selectedPaymentLine.set_amount(NumberBuffer.getFloat());
+ }
+ }
+ toggleIsToInvoice() {
+ // click_invoice
+ this.currentOrder.set_to_invoice(!this.currentOrder.is_to_invoice());
+ this.render();
+ }
+ toggleIsToEmail() {
+ // click_email
+ this.currentOrder.set_to_email(!this.currentOrder.is_to_email());
+ this.render();
+ }
+ openCashbox() {
+ this.env.pos.proxy.printer.open_cashbox();
+ }
+ async addTip() {
+ // click_tip
+ const tip = this.currentOrder.get_tip();
+ const change = this.currentOrder.get_change();
+ let value = tip.toFixed(this.env.pos.decimals);
+
+ if (tip === 0 && change > 0) {
+ value = change;
+ }
+
+ const { confirmed, payload } = await this.showPopup('NumberPopup', {
+ title: tip ? this.env._t('Change Tip') : this.env._t('Add Tip'),
+ startingValue: value,
+ });
+
+ if (confirmed) {
+ this.currentOrder.set_tip(parse.float(payload));
+ }
+ }
+ deletePaymentLine(event) {
+ const { cid } = event.detail;
+ const line = this.paymentLines.find((line) => line.cid === cid);
+
+ // If a paymentline with a payment terminal linked to
+ // it is removed, the terminal should get a cancel
+ // request.
+ if (['waiting', 'waitingCard', 'timeout'].includes(line.get_payment_status())) {
+ line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid);
+ }
+
+ this.currentOrder.remove_paymentline(line);
+ NumberBuffer.reset();
+ this.render();
+ }
+ selectPaymentLine(event) {
+ const { cid } = event.detail;
+ const line = this.paymentLines.find((line) => line.cid === cid);
+ this.currentOrder.select_paymentline(line);
+ NumberBuffer.reset();
+ this.render();
+ }
+ async validateOrder(isForceValidate) {
+ if (await this._isOrderValid(isForceValidate)) {
+ // remove pending payments before finalizing the validation
+ for (let line of this.paymentLines) {
+ if (!line.is_done()) this.currentOrder.remove_paymentline(line);
+ }
+ await this._finalizeValidation();
+ }
+ }
+ async _finalizeValidation() {
+ if (this.currentOrder.is_paid_with_cash() && this.env.pos.config.iface_cashdrawer) {
+ this.env.pos.proxy.printer.open_cashbox();
+ }
+
+ this.currentOrder.initialize_validation_date();
+ this.currentOrder.finalized = true;
+
+ let syncedOrderBackendIds = [];
+ let errorCode;
+
+ try {
+ if (this.currentOrder.is_to_invoice()) {
+ syncedOrderBackendIds = await this.env.pos.push_and_invoice_order(
+ this.currentOrder
+ );
+ } else {
+ syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder);
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ errorCode = error.code;
+ await this._handlePushOrderError(error);
+ }
+ }
+ if (syncedOrderBackendIds.length && this.currentOrder.wait_for_push_order()) {
+ const result = await this._postPushOrderResolve(
+ this.currentOrder,
+ syncedOrderBackendIds
+ );
+ if (!result) {
+ await this.showPopup('ErrorPopup', {
+ title: 'Error: no internet connection.',
+ body: error,
+ });
+ }
+ }
+
+ const shouldShowPrintInvoice = errorCode
+ ? this.currentOrder.is_to_invoice() && errorCode < 0
+ : false;
+ this.showScreen('ReceiptScreen', { printInvoiceIsShown: shouldShowPrintInvoice });
+
+ // If we succeeded in syncing the current order, and
+ // there are still other orders that are left unsynced,
+ // we ask the user if he is willing to wait and sync them.
+ if (syncedOrderBackendIds.length && this.env.pos.db.get_orders().length) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Remaining unsynced orders'),
+ body: this.env._t(
+ 'There are unsynced orders. Do you want to sync these orders?'
+ ),
+ });
+ if (confirmed) {
+ // NOTE: Not yet sure if this should be awaited or not.
+ // If awaited, some operations like changing screen
+ // might not work.
+ this.env.pos.push_orders();
+ }
+ }
+ }
+ async _isOrderValid(isForceValidate) {
+ if (this.currentOrder.get_orderlines().length === 0) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Empty Order'),
+ body: this.env._t(
+ 'There must be at least one product in your order before it can be validated'
+ ),
+ });
+ return false;
+ }
+
+ if (this.currentOrder.is_to_invoice() && !this.currentOrder.get_client()) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Please select the Customer'),
+ body: this.env._t(
+ 'You need to select the customer before you can invoice an order.'
+ ),
+ });
+ if (confirmed) {
+ this.selectClient();
+ }
+ return false;
+ }
+
+ if (!this.currentOrder.is_paid() || this.invoicing) {
+ return false;
+ }
+
+ if (this.currentOrder.has_not_valid_rounding()) {
+ var line = this.currentOrder.has_not_valid_rounding();
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Incorrect rounding'),
+ body: this.env._t(
+ 'You have to round your payments lines.' + line.amount + ' is not rounded.'
+ ),
+ });
+ return false;
+ }
+
+ // The exact amount must be paid if there is no cash payment method defined.
+ if (
+ Math.abs(
+ this.currentOrder.get_total_with_tax() - this.currentOrder.get_total_paid() + this.currentOrder.get_rounding_applied()
+ ) > 0.00001
+ ) {
+ var cash = false;
+ for (var i = 0; i < this.env.pos.payment_methods.length; i++) {
+ cash = cash || this.env.pos.payment_methods[i].is_cash_count;
+ }
+ if (!cash) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Cannot return change without a cash payment method'),
+ body: this.env._t(
+ 'There is no cash payment method available in this point of sale to handle the change.\n\n Please pay the exact amount or add a cash payment method in the point of sale configuration'
+ ),
+ });
+ return false;
+ }
+ }
+
+ var client = this.currentOrder.get_client();
+ if (
+ this.currentOrder.is_to_email() &&
+ (!client || (client && !is_email(client.email)))
+ ) {
+ var title = !client ? 'Please select the customer' : 'Please provide valid email';
+ var body = !client
+ ? 'You need to select the customer before you can send the receipt via email.'
+ : 'This customer does not have a valid email address, define one or do not send an email.';
+
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t(title),
+ body: this.env._t(body),
+ });
+ if (confirmed) {
+ this.selectClient();
+ }
+ return false;
+ }
+
+ // if the change is too large, it's probably an input error, make the user confirm.
+ if (
+ !isForceValidate &&
+ this.currentOrder.get_total_with_tax() > 0 &&
+ this.currentOrder.get_total_with_tax() * 1000 < this.currentOrder.get_total_paid()
+ ) {
+ this.showPopup('ConfirmPopup', {
+ title: this.env._t('Please Confirm Large Amount'),
+ body:
+ this.env._t('Are you sure that the customer wants to pay') +
+ ' ' +
+ this.env.pos.format_currency(this.currentOrder.get_total_paid()) +
+ ' ' +
+ this.env._t('for an order of') +
+ ' ' +
+ this.env.pos.format_currency(this.currentOrder.get_total_with_tax()) +
+ ' ' +
+ this.env._t('? Clicking "Confirm" will validate the payment.'),
+ }).then(({ confirmed }) => {
+ if (confirmed) this.validateOrder(true);
+ });
+ return false;
+ }
+
+ return true;
+ }
+ async _postPushOrderResolve(order, order_server_ids) {
+ if (order.is_to_email()) {
+ return await this._sendReceiptToCustomer(order_server_ids);
+ } else {
+ return true;
+ }
+ }
+ async _sendReceiptToCustomer(order_server_ids) {
+ const order = this.currentOrder;
+ const fixture = document.createElement('div');
+ const orderReceipt = new OrderReceipt(this, { order });
+ // Important to mount the component to a HTMLElement.
+ // If not properly mounted, HTMLElement (el) corresponding
+ // the component is not created.
+ await orderReceipt.mount(fixture);
+ const receiptString = orderReceipt.el.outerHTML;
+ fixture.remove();
+ const printer = new Printer();
+ const ticketImage = await printer.htmlToImg(receiptString);
+ const orderName = order.get_name();
+ const orderClient = order.get_client();
+ try {
+ await this.rpc({
+ model: 'pos.order',
+ method: 'action_receipt_to_customer',
+ args: [order_server_ids, orderName, orderClient, ticketImage],
+ });
+ } catch (error) {
+ order.set_to_email(false);
+ return false;
+ }
+ return true;
+ }
+ async _sendPaymentRequest({ detail: line }) {
+ // Other payment lines can not be reversed anymore
+ this.paymentLines.forEach(function (line) {
+ line.can_be_reversed = false;
+ });
+
+ const payment_terminal = line.payment_method.payment_terminal;
+ line.set_payment_status('waiting');
+
+ const isPaymentSuccessful = await payment_terminal.send_payment_request(line.cid);
+ if (isPaymentSuccessful) {
+ line.set_payment_status('done');
+ line.can_be_reversed = this.payment_interface.supports_reversals;
+ } else {
+ line.set_payment_status('retry');
+ }
+ }
+ async _sendPaymentCancel({ detail: line }) {
+ const payment_terminal = line.payment_method.payment_terminal;
+ line.set_payment_status('waitingCancel');
+ try {
+ payment_terminal.send_payment_cancel(this.currentOrder, line.cid);
+ } finally {
+ line.set_payment_status('retry');
+ }
+ }
+ async _sendPaymentReverse({ detail: line }) {
+ const payment_terminal = line.payment_method.payment_terminal;
+ line.set_payment_status('reversing');
+
+ const isReversalSuccessful = await payment_terminal.send_payment_reversal(line.cid);
+ if (isReversalSuccessful) {
+ line.set_amount(0);
+ line.set_payment_status('reversed');
+ } else {
+ line.can_be_reversed = false;
+ line.set_payment_status('done');
+ }
+ }
+ async _sendForceDone({ detail: line }) {
+ line.set_payment_status('done');
+ }
+ _onPrevOrder(prevOrder) {
+ prevOrder.off('change', null, this);
+ prevOrder.paymentlines.off('change', null, this);
+ if (prevOrder) {
+ prevOrder.stop_electronic_payment();
+ }
+ }
+ async _onNewOrder(newOrder) {
+ newOrder.on('change', this.render, this);
+ newOrder.paymentlines.on('change', this.render, this);
+ NumberBuffer.reset();
+ await this.render();
+ }
+ }
+ PaymentScreen.template = 'PaymentScreen';
+
+ Registries.Component.add(PaymentScreen);
+
+ return PaymentScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js
new file mode 100644
index 00000000000..6cafac15c3e
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.PaymentScreenElectronicPayment', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenElectronicPayment extends PosComponent {
+ mounted() {
+ this.props.line.on('change', this.render, this);
+ }
+ willUnmount() {
+ if (this.props.line) {
+ // It could be that the line is deleted before unmounting the element.
+ this.props.line.off('change', null, this);
+ }
+ }
+ }
+ PaymentScreenElectronicPayment.template = 'PaymentScreenElectronicPayment';
+
+ Registries.Component.add(PaymentScreenElectronicPayment);
+
+ return PaymentScreenElectronicPayment;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js
new file mode 100644
index 00000000000..e661722f21e
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js
@@ -0,0 +1,18 @@
+odoo.define('point_of_sale.PaymentScreenNumpad', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenNumpad extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.decimalPoint = this.env._t.database.parameters.decimal_point;
+ }
+ }
+ PaymentScreenNumpad.template = 'PaymentScreenNumpad';
+
+ Registries.Component.add(PaymentScreenNumpad);
+
+ return PaymentScreenNumpad;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js
new file mode 100644
index 00000000000..14bed3f700f
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.PaymentScreenPaymentLines', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenPaymentLines extends PosComponent {
+ formatLineAmount(paymentline) {
+ return this.env.pos.format_currency_no_symbol(paymentline.get_amount());
+ }
+ get changeText() {
+ return this.env.pos.format_currency(this.currentOrder.get_change());
+ }
+ get totalDueText() {
+ return this.env.pos.format_currency(
+ this.currentOrder.get_total_with_tax() + this.currentOrder.get_rounding_applied()
+ );
+ }
+ get remainingText() {
+ return this.env.pos.format_currency(
+ this.currentOrder.get_due() > 0 ? this.currentOrder.get_due() : 0
+ );
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ selectedLineClass(line) {
+ return { 'payment-terminal': line.get_payment_status() };
+ }
+ unselectedLineClass(line) {
+ return {};
+ }
+ }
+ PaymentScreenPaymentLines.template = 'PaymentScreenPaymentLines';
+
+ Registries.Component.add(PaymentScreenPaymentLines);
+
+ return PaymentScreenPaymentLines;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js
new file mode 100644
index 00000000000..67341d2c84f
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js
@@ -0,0 +1,25 @@
+odoo.define('point_of_sale.ActionpadWidget', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props client
+ * @trigger click-customer
+ * @trigger click-pay
+ */
+ class ActionpadWidget extends PosComponent {
+ get isLongName() {
+ return this.client && this.client.name.length > 10;
+ }
+ get client() {
+ return this.props.client;
+ }
+ }
+ ActionpadWidget.template = 'ActionpadWidget';
+
+ Registries.Component.add(ActionpadWidget);
+
+ return ActionpadWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js
new file mode 100644
index 00000000000..843cc248472
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.CategoryBreadcrumb', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class CategoryBreadcrumb extends PosComponent {}
+ CategoryBreadcrumb.template = 'CategoryBreadcrumb';
+
+ Registries.Component.add(CategoryBreadcrumb);
+
+ return CategoryBreadcrumb;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
new file mode 100644
index 00000000000..05914becdb0
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
@@ -0,0 +1,18 @@
+odoo.define('point_of_sale.CategoryButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class CategoryButton extends PosComponent {
+ get imageUrl() {
+ const category = this.props.category
+ return `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
+ }
+ }
+ CategoryButton.template = 'CategoryButton';
+
+ Registries.Component.add(CategoryButton);
+
+ return CategoryButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js
new file mode 100644
index 00000000000..675512d8704
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.CategorySimpleButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class CategorySimpleButton extends PosComponent {}
+ CategorySimpleButton.template = 'CategorySimpleButton';
+
+ Registries.Component.add(CategorySimpleButton);
+
+ return CategorySimpleButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js
new file mode 100644
index 00000000000..901e70e7d02
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js
@@ -0,0 +1,80 @@
+odoo.define('point_of_sale.SetFiscalPositionButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ProductScreen = require('point_of_sale.ProductScreen');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ class SetFiscalPositionButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this.onClick);
+ }
+ mounted() {
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get currentFiscalPositionName() {
+ return this.currentOrder && this.currentOrder.fiscal_position
+ ? this.currentOrder.fiscal_position.display_name
+ : this.env._t('Tax');
+ }
+ async onClick() {
+ const currentFiscalPosition = this.currentOrder.fiscal_position;
+ const fiscalPosList = [
+ {
+ id: -1,
+ label: this.env._t('None'),
+ isSelected: !currentFiscalPosition,
+ },
+ ];
+ for (let fiscalPos of this.env.pos.fiscal_positions) {
+ fiscalPosList.push({
+ id: fiscalPos.id,
+ label: fiscalPos.name,
+ isSelected: currentFiscalPosition
+ ? fiscalPos.id === currentFiscalPosition.id
+ : false,
+ item: fiscalPos,
+ });
+ }
+ const { confirmed, payload: selectedFiscalPosition } = await this.showPopup(
+ 'SelectionPopup',
+ {
+ title: this.env._t('Select Fiscal Position'),
+ list: fiscalPosList,
+ }
+ );
+ if (confirmed) {
+ this.currentOrder.fiscal_position = selectedFiscalPosition;
+ // IMPROVEMENT: The following is the old implementation and I believe
+ // there could be a better way of doing it.
+ for (let line of this.currentOrder.orderlines.models) {
+ line.set_quantity(line.quantity);
+ }
+ this.currentOrder.trigger('change');
+ }
+ }
+ }
+ SetFiscalPositionButton.template = 'SetFiscalPositionButton';
+
+ ProductScreen.addControlButton({
+ component: SetFiscalPositionButton,
+ condition: function() {
+ return this.env.pos.fiscal_positions.length > 0;
+ },
+ position: ['before', 'SetPricelistButton'],
+ });
+
+ Registries.Component.add(SetFiscalPositionButton);
+
+ return SetFiscalPositionButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js
new file mode 100644
index 00000000000..c0a01f873a3
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js
@@ -0,0 +1,67 @@
+odoo.define('point_of_sale.SetPricelistButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ProductScreen = require('point_of_sale.ProductScreen');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ class SetPricelistButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this.onClick);
+ }
+ mounted() {
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get currentPricelistName() {
+ const order = this.currentOrder;
+ return order && order.pricelist
+ ? order.pricelist.display_name
+ : this.env._t('Pricelist');
+ }
+ async onClick() {
+ // Create the list to be passed to the SelectionPopup.
+ // Pricelist object is passed as item in the list because it
+ // is the object that will be returned when the popup is confirmed.
+ const selectionList = this.env.pos.pricelists.map(pricelist => ({
+ id: pricelist.id,
+ label: pricelist.name,
+ isSelected: pricelist.id === this.currentOrder.pricelist.id,
+ item: pricelist,
+ }));
+
+ const { confirmed, payload: selectedPricelist } = await this.showPopup(
+ 'SelectionPopup',
+ {
+ title: this.env._t('Select the pricelist'),
+ list: selectionList,
+ }
+ );
+
+ if (confirmed) {
+ this.currentOrder.set_pricelist(selectedPricelist);
+ }
+ }
+ }
+ SetPricelistButton.template = 'SetPricelistButton';
+
+ ProductScreen.addControlButton({
+ component: SetPricelistButton,
+ condition: function() {
+ return this.env.pos.config.use_pricelist && this.env.pos.pricelists.length > 1;
+ },
+ });
+
+ Registries.Component.add(SetPricelistButton);
+
+ return SetPricelistButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js
new file mode 100644
index 00000000000..4c170777a1b
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.HomeCategoryBreadcrumb', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class HomeCategoryBreadcrumb extends PosComponent {}
+ HomeCategoryBreadcrumb.template = 'HomeCategoryBreadcrumb';
+
+ Registries.Component.add(HomeCategoryBreadcrumb);
+
+ return HomeCategoryBreadcrumb;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js
new file mode 100644
index 00000000000..6feda10ec9a
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js
@@ -0,0 +1,47 @@
+odoo.define('point_of_sale.NumpadWidget', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * IMPROVEMENT: Whenever new-orderline-selected is triggered,
+ * numpad mode should be set to 'quantity'.
+ */
+ class NumpadWidget extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ mode: 'quantity' });
+ }
+ mounted() {
+ this.env.pos.on('change:cashier', () => {
+ if (!this.hasPriceControlRights && this.state.mode === 'price') {
+ this.state.mode = 'quantity';
+ }
+ });
+ }
+ willUnmount() {
+ this.env.pos.on('change:cashier', null, this);
+ }
+ get hasPriceControlRights() {
+ const cashier = this.env.pos.get('cashier') || this.env.pos.get_cashier();
+ return !this.env.pos.config.restrict_price_control || cashier.role == 'manager';
+ }
+ changeMode(mode) {
+ if (!this.hasPriceControlRights && mode === 'price') {
+ return;
+ }
+ this.state.mode = mode;
+ this.trigger('set-numpad-mode', { mode });
+ }
+ sendInput(key) {
+ this.trigger('numpad-click-input', { key });
+ }
+ }
+ NumpadWidget.template = 'NumpadWidget';
+
+ Registries.Component.add(NumpadWidget);
+
+ return NumpadWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js
new file mode 100644
index 00000000000..aeb9891fbfc
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.OrderSummary', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class OrderSummary extends PosComponent {}
+ OrderSummary.template = 'OrderSummary';
+
+ Registries.Component.add(OrderSummary);
+
+ return OrderSummary;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js
new file mode 100644
index 00000000000..ee610afde01
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js
@@ -0,0 +1,110 @@
+odoo.define('point_of_sale.OrderWidget', function(require) {
+ 'use strict';
+
+ const { useState, useRef, onPatched } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const { onChangeOrder } = require('point_of_sale.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class OrderWidget extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('select-line', this._selectLine);
+ useListener('edit-pack-lot-lines', this._editPackLotLines);
+ onChangeOrder(this._onPrevOrder, this._onNewOrder);
+ this.scrollableRef = useRef('scrollable');
+ this.scrollToBottom = false;
+ onPatched(() => {
+ // IMPROVEMENT
+ // This one just stays at the bottom of the orderlines list.
+ // Perhaps it is better to scroll to the added or modified orderline.
+ if (this.scrollToBottom) {
+ this.scrollableRef.el.scrollTop = this.scrollableRef.el.scrollHeight;
+ this.scrollToBottom = false;
+ }
+ });
+ this.state = useState({ total: 0, tax: 0 });
+ this._updateSummary();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ get orderlinesArray() {
+ return this.order ? this.order.get_orderlines() : [];
+ }
+ _selectLine(event) {
+ this.order.select_orderline(event.detail.orderline);
+ }
+ // IMPROVEMENT: Might be better to lift this to ProductScreen
+ // because there is similar operation when clicking a product.
+ //
+ // Furthermore, what if a number different from 1 (or -1) is specified
+ // to an orderline that has product tracked by lot. Lot tracking (based
+ // on the current implementation) requires that 1 item per orderline is
+ // allowed.
+ async _editPackLotLines(event) {
+ const orderline = event.detail.orderline;
+ const isAllowOnlyOneLot = orderline.product.isAllowOnlyOneLot();
+ const packLotLinesToEdit = orderline.getPackLotLinesToEdit(isAllowOnlyOneLot);
+ const { confirmed, payload } = await this.showPopup('EditListPopup', {
+ title: this.env._t('Lot/Serial Number(s) Required'),
+ isSingleItem: isAllowOnlyOneLot,
+ array: packLotLinesToEdit,
+ });
+ if (confirmed) {
+ // Segregate the old and new packlot lines
+ const modifiedPackLotLines = Object.fromEntries(
+ payload.newArray.filter(item => item.id).map(item => [item.id, item.text])
+ );
+ const newPackLotLines = payload.newArray
+ .filter(item => !item.id)
+ .map(item => ({ lot_name: item.text }));
+
+ orderline.setPackLotLines({ modifiedPackLotLines, newPackLotLines });
+ }
+ this.order.select_orderline(event.detail.orderline);
+ }
+ _onNewOrder(order) {
+ if (order) {
+ order.orderlines.on(
+ 'new-orderline-selected',
+ () => this.trigger('new-orderline-selected'),
+ this
+ );
+ order.orderlines.on('change', this._updateSummary, this);
+ order.orderlines.on(
+ 'add remove',
+ () => {
+ this.scrollToBottom = true;
+ this._updateSummary();
+ },
+ this
+ );
+ order.on('change', this.render, this);
+ }
+ this._updateSummary();
+ this.trigger('new-orderline-selected');
+ }
+ _onPrevOrder(order) {
+ if (order) {
+ order.orderlines.off('new-orderline-selected', null, this);
+ order.orderlines.off('change', null, this);
+ order.orderlines.off('add remove', null, this);
+ order.off('change', null, this);
+ }
+ }
+ _updateSummary() {
+ const total = this.order ? this.order.get_total_with_tax() : 0;
+ const tax = this.order ? total - this.order.get_total_without_tax() : 0;
+ this.state.total = this.env.pos.format_currency(total);
+ this.state.tax = this.env.pos.format_currency(tax);
+ this.render();
+ }
+ }
+ OrderWidget.template = 'OrderWidget';
+
+ Registries.Component.add(OrderWidget);
+
+ return OrderWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js
new file mode 100644
index 00000000000..71a96bd4129
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js
@@ -0,0 +1,25 @@
+odoo.define('point_of_sale.Orderline', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class Orderline extends PosComponent {
+ selectLine() {
+ this.trigger('select-line', { orderline: this.props.line });
+ }
+ lotIconClicked() {
+ this.trigger('edit-pack-lot-lines', { orderline: this.props.line });
+ }
+ get addedClasses() {
+ return {
+ selected: this.props.line.selected,
+ };
+ }
+ }
+ Orderline.template = 'Orderline';
+
+ Registries.Component.add(Orderline);
+
+ return Orderline;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
new file mode 100644
index 00000000000..ac93500c4a5
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
@@ -0,0 +1,49 @@
+odoo.define('point_of_sale.ProductItem', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductItem extends PosComponent {
+ /**
+ * For accessibility, pressing <space> should be like clicking the product.
+ * <enter> is not considered because it conflicts with the barcode.
+ *
+ * @param {KeyPressEvent} event
+ */
+ spaceClickProduct(event) {
+ if (event.which === 32) {
+ this.trigger('click-product', this.props.product);
+ }
+ }
+ get imageUrl() {
+ const product = this.props.product;
+ return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ }
+ get pricelist() {
+ const current_order = this.env.pos.get_order();
+ if (current_order) {
+ return current_order.pricelist;
+ }
+ return this.env.pos.default_pricelist;
+ }
+ get price() {
+ const formattedUnitPrice = this.env.pos.format_currency(
+ this.props.product.get_price(this.pricelist, 1),
+ 'Product Price'
+ );
+ if (this.props.product.to_weight) {
+ return `${formattedUnitPrice}/${
+ this.env.pos.units_by_id[this.props.product.uom_id[0]].name
+ }`;
+ } else {
+ return formattedUnitPrice;
+ }
+ }
+ }
+ ProductItem.template = 'ProductItem';
+
+ Registries.Component.add(ProductItem);
+
+ return ProductItem;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js
new file mode 100644
index 00000000000..aeee2ede905
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.ProductList', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductList extends PosComponent {}
+ ProductList.template = 'ProductList';
+
+ Registries.Component.add(ProductList);
+
+ return ProductList;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
new file mode 100644
index 00000000000..a9945b42616
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
@@ -0,0 +1,338 @@
+odoo.define('point_of_sale.ProductScreen', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+ const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks');
+
+ class ProductScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('update-selected-orderline', this._updateSelectedOrderline);
+ useListener('new-orderline-selected', this._newOrderlineSelected);
+ useListener('set-numpad-mode', this._setNumpadMode);
+ useListener('click-product', this._clickProduct);
+ useListener('click-customer', this._onClickCustomer);
+ useListener('click-pay', this._onClickPay);
+ useBarcodeReader({
+ product: this._barcodeProductAction,
+ weight: this._barcodeProductAction,
+ price: this._barcodeProductAction,
+ client: this._barcodeClientAction,
+ discount: this._barcodeDiscountAction,
+ error: this._barcodeErrorAction,
+ })
+ onChangeOrder(null, (newOrder) => newOrder && this.render());
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ triggerAtInput: 'update-selected-orderline',
+ useWithBarcode: true,
+ });
+ this.numpadMode = 'quantity';
+ }
+ mounted() {
+ this.env.pos.on('change:selectedClient', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedClient', null, this);
+ }
+ /**
+ * To be overridden by modules that checks availability of
+ * connected scale.
+ * @see _onScaleNotAvailable
+ */
+ get isScaleAvailable() {
+ return true;
+ }
+ get client() {
+ return this.env.pos.get_client();
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get controlButtons() {
+ return ProductScreen.controlButtons
+ .filter((cb) => {
+ return cb.condition.bind(this)();
+ })
+ .map((cb) =>
+ Object.assign({}, cb, { component: Registries.Component.get(cb.component) })
+ );
+ }
+ async _clickProduct(event) {
+ if (!this.currentOrder) {
+ this.env.pos.add_new_order();
+ }
+ const product = event.detail;
+ let draftPackLotLines, weight, packLotLinesToEdit;
+
+ // Gather lot information if required.
+ if (['serial', 'lot'].includes(product.tracking)) {
+ const isAllowOnlyOneLot = product.isAllowOnlyOneLot();
+ if (isAllowOnlyOneLot) {
+ packLotLinesToEdit = [];
+ } else {
+ const orderline = this.currentOrder
+ .get_orderlines()
+ .filter(line => !line.get_discount())
+ .find(line => line.product.id === product.id);
+ if (orderline) {
+ packLotLinesToEdit = orderline.getPackLotLinesToEdit();
+ } else {
+ packLotLinesToEdit = [];
+ }
+ }
+ const { confirmed, payload } = await this.showPopup('EditListPopup', {
+ title: this.env._t('Lot/Serial Number(s) Required'),
+ isSingleItem: isAllowOnlyOneLot,
+ array: packLotLinesToEdit,
+ });
+ if (confirmed) {
+ // Segregate the old and new packlot lines
+ const modifiedPackLotLines = Object.fromEntries(
+ payload.newArray.filter(item => item.id).map(item => [item.id, item.text])
+ );
+ const newPackLotLines = payload.newArray
+ .filter(item => !item.id)
+ .map(item => ({ lot_name: item.text }));
+
+ draftPackLotLines = { modifiedPackLotLines, newPackLotLines };
+ } else {
+ // We don't proceed on adding product.
+ return;
+ }
+ }
+
+ // Take the weight if necessary.
+ if (product.to_weight && this.env.pos.config.iface_electronic_scale) {
+ // Show the ScaleScreen to weigh the product.
+ if (this.isScaleAvailable) {
+ const { confirmed, payload } = await this.showTempScreen('ScaleScreen', {
+ product,
+ });
+ if (confirmed) {
+ weight = payload.weight;
+ } else {
+ // do not add the product;
+ return;
+ }
+ } else {
+ await this._onScaleNotAvailable();
+ }
+ }
+
+ // Add the product after having the extra information.
+ this.currentOrder.add_product(product, {
+ draftPackLotLines,
+ quantity: weight,
+ });
+
+ NumberBuffer.reset();
+ }
+ async _setNumpadMode(event) {
+ const { mode } = event.detail;
+ this.numpadMode = mode;
+ NumberBuffer.reset();
+ }
+ disallowLineQuantityChange() {
+ //This function will check if we should check or not the way we handle the line deletion.
+ //If the localisation prevent the line deletion, this function will return true, following the override in the multiple modules.
+ //By default, we'll always set the quantity according to the buffer input.
+ return false;
+ }
+ async _updateSelectedOrderline(event) {
+ if(this.disallowLineQuantityChange()) {
+ let order = this.env.pos.get_order();
+ let selectedLine = order.get_selected_orderline();
+ let last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length-1];
+ let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity();
+
+ if(last_id != selectedLine.cid)
+ this._showDecreaseQuantityPopup();
+ else if(currentQuantity === 1 && event.detail.buffer < 1) // Allow to put a value below 1 for the last product entry.
+ this._setValue(event.detail.buffer);
+ else if(currentQuantity < event.detail.buffer)
+ this._setValue(event.detail.buffer);
+ else if(event.detail.buffer < currentQuantity)
+ this._showDecreaseQuantityPopup();
+ } else {
+ let { buffer } = event.detail;
+ let val = buffer === null ? 'remove' : buffer;
+ this._setValue(val);
+ }
+ }
+ async _newOrderlineSelected() {
+ NumberBuffer.reset();
+ }
+ _setValue(val) {
+ if (this.currentOrder.get_selected_orderline()) {
+ if (this.numpadMode === 'quantity') {
+ this.currentOrder.get_selected_orderline().set_quantity(val);
+ } else if (this.numpadMode === 'discount') {
+ this.currentOrder.get_selected_orderline().set_discount(val);
+ } else if (this.numpadMode === 'price') {
+ var selected_orderline = this.currentOrder.get_selected_orderline();
+ selected_orderline.price_manually_set = true;
+ selected_orderline.set_unit_price(val);
+ }
+ if (this.env.pos.config.iface_customer_facing_display) {
+ this.env.pos.send_current_order_to_customer_facing_display();
+ }
+ }
+ }
+ _barcodeProductAction(code) {
+ // NOTE: scan_product call has side effect in pos if it returned true.
+ if (!this.env.pos.scan_product(code)) {
+ this._barcodeErrorAction(code);
+ }
+ }
+ _barcodeClientAction(code) {
+ const partner = this.env.pos.db.get_partner_by_barcode(code.code);
+ if (partner) {
+ if (this.currentOrder.get_client() !== partner) {
+ this.currentOrder.set_client(partner);
+ this.currentOrder.set_pricelist(
+ _.findWhere(this.env.pos.pricelists, {
+ id: partner.property_product_pricelist[0],
+ }) || this.env.pos.default_pricelist
+ );
+ }
+ return true;
+ }
+ this._barcodeErrorAction(code);
+ return false;
+ }
+ _barcodeDiscountAction(code) {
+ var last_orderline = this.currentOrder.get_last_orderline();
+ if (last_orderline) {
+ last_orderline.set_discount(code.value);
+ }
+ }
+ // IMPROVEMENT: The following two methods should be in PosScreenComponent?
+ // Why? Because once we start declaring barcode actions in different
+ // screens, these methods will also be declared over and over.
+ _barcodeErrorAction(code) {
+ this.showPopup('ErrorBarcodePopup', { code: this._codeRepr(code) });
+ }
+ _codeRepr(code) {
+ if (code.code.length > 32) {
+ return code.code.substring(0, 29) + '...';
+ } else {
+ return code.code;
+ }
+ }
+ /**
+ * override this method to perform procedure if the scale is not available.
+ * @see isScaleAvailable
+ */
+ async _onScaleNotAvailable() {}
+ async _showDecreaseQuantityPopup() {
+ const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', {
+ startingValue: 0,
+ title: this.env._t('Decrease the quantity by'),
+ });
+ let decreaseQuantity = Math.abs(inputNumber);
+ if (confirmed && decreaseQuantity) {
+ let order = this.env.pos.get_order();
+ let selectedLine = this.env.pos.get_order().get_selected_orderline();
+
+ let quantity = 0;
+ order.get_orderlines().forEach(orderLine => {
+ if(orderLine.get_product().id === selectedLine.get_product().id && orderLine.get_discount() === selectedLine.get_discount())
+ quantity += orderLine.get_quantity();
+ });
+
+ if(decreaseQuantity > quantity) {
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Order error'),
+ body: this.env._t('Not allowed to take back more than was ordered.'),
+ });
+ } else {
+ let decreaseLine = selectedLine.clone();
+ decreaseLine.order = order;
+ decreaseLine.set_quantity(-decreaseQuantity);
+ order.add_orderline(decreaseLine);
+ }
+ }
+ }
+ async _onClickCustomer() {
+ // IMPROVEMENT: This code snippet is very similar to selectClient of PaymentScreen.
+ const currentClient = this.currentOrder.get_client();
+ const { confirmed, payload: newClient } = await this.showTempScreen(
+ 'ClientListScreen',
+ { client: currentClient }
+ );
+ if (confirmed) {
+ this.currentOrder.set_client(newClient);
+ this.currentOrder.updatePricelist(newClient);
+ }
+ }
+ _onClickPay() {
+ this.showScreen('PaymentScreen');
+ }
+ }
+ ProductScreen.template = 'ProductScreen';
+
+ ProductScreen.controlButtons = [];
+
+ /**
+ * @param {Object} controlButton
+ * @param {Function} controlButton.component
+ * Can be any base class or base class callback that is added in the Registries.Component.
+ * @param {Function} controlButton.condition zero argument function that is bound
+ * to the instance of ProductScreen, such that `this.env.pos` can be used
+ * inside the function.
+ * @param {Array} [controlButton.position] array of two elements
+ * [locator, relativeTo]
+ * locator: string -> any of ('before', 'after', 'replace')
+ * relativeTo: string -> other controlButtons component name
+ */
+ ProductScreen.addControlButton = function(controlButton) {
+ // We set the name first.
+ if (!controlButton.name) {
+ controlButton.name = controlButton.component.name;
+ }
+
+ // If no position is set, we just push it to the array.
+ if (!controlButton.position) {
+ this.controlButtons.push(controlButton);
+ } else {
+ // Find where to put the new controlButton.
+ const [locator, relativeTo] = controlButton.position;
+ let whereIndex = -1;
+ for (let i = 0; i < this.controlButtons.length; i++) {
+ if (this.controlButtons[i].name === relativeTo) {
+ if (['before', 'replace'].includes(locator)) {
+ whereIndex = i;
+ } else if (locator === 'after') {
+ whereIndex = i + 1;
+ }
+ break;
+ }
+ }
+
+ // If found where to put, then perform the necessary mutation of
+ // the buttons array.
+ // Else, we just push this controlButton to the array.
+ if (whereIndex > -1) {
+ this.controlButtons.splice(
+ whereIndex,
+ locator === 'replace' ? 1 : 0,
+ controlButton
+ );
+ } else {
+ let warningMessage =
+ `'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` +
+ 'It is pushed to the controlButtons stack instead.';
+ console.warn(warningMessage);
+ this.controlButtons.push(controlButton);
+ }
+ }
+ };
+
+ Registries.Component.add(ProductScreen);
+
+ return ProductScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js
new file mode 100644
index 00000000000..5bb49de5f07
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js
@@ -0,0 +1,72 @@
+odoo.define('point_of_sale.ProductsWidget', function(require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductsWidget extends PosComponent {
+ /**
+ * @param {Object} props
+ * @param {number?} props.startCategoryId
+ */
+ constructor() {
+ super(...arguments);
+ useListener('switch-category', this._switchCategory);
+ useListener('update-search', this._updateSearch);
+ useListener('clear-search', this._clearSearch);
+ this.state = useState({ searchWord: '' });
+ }
+ mounted() {
+ this.env.pos.on('change:selectedCategoryId', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedCategoryId', null, this);
+ }
+ get selectedCategoryId() {
+ return this.env.pos.get('selectedCategoryId');
+ }
+ get searchWord() {
+ return this.state.searchWord.trim();
+ }
+ get productsToDisplay() {
+ if (this.searchWord !== '') {
+ return this.env.pos.db.search_product_in_category(
+ this.selectedCategoryId,
+ this.searchWord
+ );
+ } else {
+ return this.env.pos.db.get_product_by_category(this.selectedCategoryId);
+ }
+ }
+ get subcategories() {
+ return this.env.pos.db
+ .get_category_childs_ids(this.selectedCategoryId)
+ .map(id => this.env.pos.db.get_category_by_id(id));
+ }
+ get breadcrumbs() {
+ if (this.selectedCategoryId === this.env.pos.db.root_category_id) return [];
+ return [
+ ...this.env.pos.db
+ .get_category_ancestors_ids(this.selectedCategoryId)
+ .slice(1),
+ this.selectedCategoryId,
+ ].map(id => this.env.pos.db.get_category_by_id(id));
+ }
+ _switchCategory(event) {
+ this.env.pos.set('selectedCategoryId', event.detail);
+ }
+ _updateSearch(event) {
+ this.state.searchWord = event.detail;
+ }
+ _clearSearch() {
+ this.state.searchWord = '';
+ }
+ }
+ ProductsWidget.template = 'ProductsWidget';
+
+ Registries.Component.add(ProductsWidget);
+
+ return ProductsWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js
new file mode 100644
index 00000000000..ec1897f1fb5
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js
@@ -0,0 +1,29 @@
+odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) {
+ 'use strict';
+
+ const { useRef } = owl.hooks;
+ const { debounce } = owl.utils;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductsWidgetControlPanel extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.searchTimeout = null;
+ this.searchWordInput = useRef('search-word-input');
+ this.updateSearch = debounce(this.updateSearch, 100);
+ }
+ clearSearch() {
+ this.searchWordInput.el.value = '';
+ this.trigger('clear-search');
+ }
+ updateSearch(event) {
+ this.trigger('update-search', event.target.value);
+ }
+ }
+ ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel';
+
+ Registries.Component.add(ProductsWidgetControlPanel);
+
+ return ProductsWidgetControlPanel;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js
new file mode 100644
index 00000000000..39538809ca4
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js
@@ -0,0 +1,47 @@
+odoo.define('point_of_sale.OrderReceipt', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class OrderReceipt extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this._receiptEnv = this.props.order.getOrderReceiptEnv();
+ }
+ willUpdateProps(nextProps) {
+ this._receiptEnv = nextProps.order.getOrderReceiptEnv();
+ }
+ get receipt() {
+ return this.receiptEnv.receipt;
+ }
+ get orderlines() {
+ return this.receiptEnv.orderlines;
+ }
+ get paymentlines() {
+ return this.receiptEnv.paymentlines;
+ }
+ get isTaxIncluded() {
+ return Math.abs(this.receipt.subtotal - this.receipt.total_with_tax) <= 0.000001;
+ }
+ get receiptEnv () {
+ return this._receiptEnv;
+ }
+ isSimple(line) {
+ return (
+ line.discount === 0 &&
+ line.unit_name === 'Units' &&
+ line.quantity === 1 &&
+ !(
+ line.display_discount_policy == 'without_discount' &&
+ line.price != line.price_lst
+ )
+ );
+ }
+ }
+ OrderReceipt.template = 'OrderReceipt';
+
+ Registries.Component.add(OrderReceipt);
+
+ return OrderReceipt;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
new file mode 100644
index 00000000000..8faa786c661
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
@@ -0,0 +1,132 @@
+odoo.define('point_of_sale.ReceiptScreen', function(require) {
+ 'use strict';
+
+ const core = require('web.core');
+ const { useRef, useState } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ const _t = core._t;
+
+ class ReceiptScreen extends PosComponent {
+ /**
+ * Optional props:
+ * printInvoiceIsShown: Boolean
+ */
+ constructor() {
+ super(...arguments);
+ useErrorHandlers();
+ onChangeOrder(null, (newOrder) => newOrder && this.render());
+ this.state = useState({ printInvoiceIsShown: this.props.printInvoiceIsShown });
+ this.orderReceipt = useRef('order-receipt');
+ }
+ mounted() {
+ // Here, we send a task to the event loop that handles
+ // the printing of the receipt when the component is mounted.
+ // We are doing this because we want the receipt screen to be
+ // displayed regardless of what happen to the handleAutoPrint
+ // call.
+ setTimeout(async () => await this.handleAutoPrint(), 0);
+ }
+ get change() {
+ return this.env.pos.format_currency(this.currentOrder.get_change());
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get nextScreen() {
+ return { name: 'ProductScreen' };
+ }
+ /**
+ * This function is called outside the rendering call stack. This way,
+ * we don't block the displaying of ReceiptScreen when it is mounted; additionally,
+ * any error that can happen during the printing does not affect the rendering.
+ */
+ async handleAutoPrint() {
+ if (this._shouldAutoPrint() && !this.currentOrder.is_to_email()) {
+ await this.printReceipt();
+ if (this.currentOrder._printed && this._shouldCloseImmediately()) {
+ this.orderDone();
+ }
+ }
+ }
+ orderDone() {
+ this.currentOrder.finalize();
+ const { name, props } = this.nextScreen;
+ this.showScreen(name, props);
+ }
+ async printReceipt() {
+ if (this.env.pos.proxy.printer) {
+ const printResult = await this.env.pos.proxy.printer.print_receipt(
+ this.orderReceipt.el.outerHTML
+ );
+ if (printResult.successful) {
+ this.currentOrder._printed = true;
+ } else {
+ await this.showPopup('ErrorPopup', {
+ title: printResult.message.title,
+ body: printResult.message.body,
+ });
+ }
+ } else {
+ await this._printWeb();
+ }
+ }
+ async onPrintInvoice() {
+ // The button element of this event handler appears when the order
+ // failed to sync and is to be invoiced. What we do here is to try
+ // to sync again to eventually print the invoice.
+ try {
+ await this.env.pos.push_and_invoice_order(this.currentOrder);
+ this.state.printInvoiceIsShown = false;
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ await this._handlePushOrderError(error);
+ }
+ }
+ }
+ _shouldAutoPrint() {
+ return this.env.pos.config.iface_print_auto && !this.currentOrder._printed;
+ }
+ _shouldCloseImmediately() {
+ var invoiced_finalized = this.currentOrder.is_to_invoice()
+ ? this.currentOrder.finalized
+ : true;
+ return (
+ this.env.pos.proxy.printer &&
+ this.env.pos.config.iface_print_skip_screen &&
+ invoiced_finalized
+ );
+ }
+ async _printWeb() {
+ if ($.browser.safari) {
+ document.execCommand('print', false, null);
+ } else {
+ try {
+ window.print();
+ this.currentOrder._printed = true;
+ } catch (err) {
+ if (navigator.userAgent.toLowerCase().indexOf('android') > -1) {
+ await this.showPopup('ErrorPopup', {
+ title: _t('Printing is not supported on some android browsers'),
+ body: _t(
+ 'Printing is not supported on some android browsers due to no default printing protocol ' +
+ 'is available. It is possible to print your tickets by making use of an IoT Box.'
+ ),
+ });
+ } else {
+ throw err;
+ }
+ }
+ }
+ }
+ }
+ ReceiptScreen.template = 'ReceiptScreen';
+
+ Registries.Component.add(ReceiptScreen);
+
+ return ReceiptScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js
new file mode 100644
index 00000000000..e7527eee687
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js
@@ -0,0 +1,18 @@
+odoo.define('point_of_sale.WrappedProductNameLines', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class WrappedProductNameLines extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.line = this.props.line;
+ }
+ }
+ WrappedProductNameLines.template = 'WrappedProductNameLines';
+
+ Registries.Component.add(WrappedProductNameLines);
+
+ return WrappedProductNameLines;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js
new file mode 100644
index 00000000000..f9b1ea97fb0
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js
@@ -0,0 +1,102 @@
+odoo.define('point_of_sale.ScaleScreen', function(require) {
+ 'use strict';
+
+ const { useState, useExternalListener } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { round_precision: round_pr } = require('web.utils');
+ const Registries = require('point_of_sale.Registries');
+
+ class ScaleScreen extends PosComponent {
+ /**
+ * @param {Object} props
+ * @param {Object} props.product The product to weight.
+ */
+ constructor() {
+ super(...arguments);
+ useExternalListener(document, 'keyup', this._onHotkeys);
+ this.state = useState({ weight: 0 });
+ }
+ mounted() {
+ // start the scale reading
+ this._readScale();
+ }
+ willUnmount() {
+ // stop the scale reading
+ this.env.pos.proxy_queue.clear();
+ }
+ back() {
+ this.props.resolve({ confirmed: false, payload: null });
+ this.trigger('close-temp-screen');
+ }
+ confirm() {
+ this.props.resolve({
+ confirmed: true,
+ payload: { weight: this.state.weight },
+ });
+ this.trigger('close-temp-screen');
+ }
+ _onHotkeys(event) {
+ if (event.key === 'Escape') {
+ this.back();
+ } else if (event.key === 'Enter') {
+ this.confirm();
+ }
+ }
+ _readScale() {
+ this.env.pos.proxy_queue.schedule(this._setWeight.bind(this), {
+ duration: 500,
+ repeat: true,
+ });
+ }
+ async _setWeight() {
+ const reading = await this.env.pos.proxy.scale_read();
+ this.state.weight = reading.weight;
+ }
+ get _activePricelist() {
+ const current_order = this.env.pos.get_order();
+ let current_pricelist = this.env.pos.default_pricelist;
+ if (current_order) {
+ current_pricelist = current_order.pricelist;
+ }
+ return current_pricelist;
+ }
+ get productWeightString() {
+ const defaultstr = (this.state.weight || 0).toFixed(3) + ' Kg';
+ if (!this.props.product || !this.env.pos) {
+ return defaultstr;
+ }
+ const unit_id = this.props.product.uom_id;
+ if (!unit_id) {
+ return defaultstr;
+ }
+ const unit = this.env.pos.units_by_id[unit_id[0]];
+ const weight = round_pr(this.state.weight || 0, unit.rounding);
+ let weightstr = weight.toFixed(Math.ceil(Math.log(1.0 / unit.rounding) / Math.log(10)));
+ weightstr += ' ' + unit.name;
+ return weightstr;
+ }
+ get computedPriceString() {
+ return this.env.pos.format_currency(this.productPrice * this.state.weight);
+ }
+ get productPrice() {
+ const product = this.props.product;
+ return (product ? product.get_price(this._activePricelist, this.state.weight) : 0) || 0;
+ }
+ get productName() {
+ return (
+ (this.props.product ? this.props.product.display_name : undefined) ||
+ 'Unnamed Product'
+ );
+ }
+ get productUom() {
+ return this.props.product
+ ? this.env.pos.units_by_id[this.props.product.uom_id[0]].name
+ : '';
+ }
+ }
+ ScaleScreen.template = 'ScaleScreen';
+
+ Registries.Component.add(ScaleScreen);
+
+ return ScaleScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/barcode_reader.js b/addons/point_of_sale/static/src/js/barcode_reader.js
index c332591b715..52e5c649942 100644
--- a/addons/point_of_sale/static/src/js/barcode_reader.js
+++ b/addons/point_of_sale/static/src/js/barcode_reader.js
@@ -16,7 +16,8 @@ var BarcodeReader = core.Class.extend({
init: function (attributes) {
this.pos = attributes.pos;
- this.action_callback = {};
+ this.action_callbacks = {};
+ this.exclusive_callbacks = {};
this.proxy = attributes.proxy;
this.remote_scanning = false;
this.remote_active = 0;
@@ -34,21 +35,6 @@ var BarcodeReader = core.Class.extend({
this.barcode_parser = barcode_parser;
},
- save_callbacks: function () {
- var callbacks = {};
- for (var name in this.action_callback) {
- callbacks[name] = this.action_callback[name];
- }
- this.action_callback_stack.push(callbacks);
- },
-
- restore_callbacks: function () {
- if (this.action_callback_stack.length) {
- var callbacks = this.action_callback_stack.pop();
- this.action_callback = callbacks;
- }
- },
-
// when a barcode is scanned and parsed, the callback corresponding
// to its type is called with the parsed_barcode as a parameter.
// (parsed_barcode is the result of parse_barcode(barcode))
@@ -59,36 +45,75 @@ var BarcodeReader = core.Class.extend({
//
// possible actions include :
// 'product' | 'cashier' | 'client' | 'discount'
- set_action_callback: function (action, callback) {
- if (arguments.length == 2) {
- this.action_callback[action] = callback;
+ set_action_callback: function (name, callback) {
+ if (this.action_callbacks[name]) {
+ this.action_callbacks[name].add(callback);
} else {
- var actions = arguments[0];
- for (var action in actions) {
- this.set_action_callback(action,actions[action]);
+ this.action_callbacks[name] = new Set([callback]);
+ }
+ },
+
+ remove_action_callback: function(name, callback) {
+ if (!callback) {
+ delete this.action_callbacks[name];
+ return;
+ }
+ const callbacks = this.action_callbacks[name];
+ if (callbacks) {
+ callbacks.delete(callback);
+ if (callbacks.size === 0) {
+ delete this.action_callbacks[name];
}
}
},
- //remove all action callbacks
- reset_action_callbacks: function () {
- for (var action in this.action_callback) {
- this.action_callback[action] = undefined;
+ /**
+ * Allow setting of exclusive callbacks. If there are exclusive callbacks,
+ * these callbacks are called neglecting the regular callbacks. This is
+ * useful for rendered Components that wants to take exclusive access
+ * to the barcode reader.
+ *
+ * @param {String} name
+ * @param {Function} callback function that takes parsed barcode
+ */
+ set_exclusive_callback: function (name, callback) {
+ if (this.exclusive_callbacks[name]) {
+ this.exclusive_callbacks[name].add(callback);
+ } else {
+ this.exclusive_callbacks[name] = new Set([callback]);
}
},
- scan: function (code) {
- if (!code) {
+ remove_exclusive_callback: function (name, callback) {
+ if (!callback) {
+ delete this.exclusive_callbacks[name];
return;
}
- var parsed_result = this.barcode_parser.parse_barcode(code);
- if (this.action_callback[parsed_result.type]) {
- this.action_callback[parsed_result.type](parsed_result);
- } else if (this.action_callback.error) {
- this.action_callback.error(parsed_result);
+ const callbacks = this.exclusive_callbacks[name];
+ if (callbacks) {
+ callbacks.delete(callback);
+ if (callbacks.size === 0) {
+ delete this.exclusive_callbacks[name];
+ }
+ }
+ },
+
+ scan: function (code) {
+ if (!code) return;
+
+ const callbacks = Object.keys(this.exclusive_callbacks).length
+ ? this.exclusive_callbacks
+ : this.action_callbacks;
+
+ const parsed_result = this.barcode_parser.parse_barcode(code);
+ if (callbacks[parsed_result.type]) {
+ [...callbacks[parsed_result.type]].map((cb) => cb(parsed_result));
+ } else if (callbacks.error) {
+ [...callbacks.error].map((cb) => cb(parsed_result));
} else {
- console.warn("Ignored Barcode Scan:", parsed_result);
+ console.warn('Ignored Barcode Scan:', parsed_result);
}
+
},
// the barcode scanner will listen on the hw_proxy/scanner interface for
diff --git a/addons/point_of_sale/static/src/js/chrome.js b/addons/point_of_sale/static/src/js/chrome.js
deleted file mode 100644
index f639c50577d..00000000000
--- a/addons/point_of_sale/static/src/js/chrome.js
+++ /dev/null
@@ -1,937 +0,0 @@
-odoo.define('point_of_sale.chrome', function (require) {
-"use strict";
-
-var PosBaseWidget = require('point_of_sale.BaseWidget');
-var gui = require('point_of_sale.gui');
-var keyboard = require('point_of_sale.keyboard');
-var models = require('point_of_sale.models');
-var AbstractAction = require('web.AbstractAction');
-var core = require('web.core');
-var ajax = require('web.ajax');
-var CrashManager = require('web.CrashManager').CrashManager;
-var rpc = require('web.rpc');
-var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents;
-
-
-var _t = core._t;
-var _lt = core._lt;
-var QWeb = core.qweb;
-
-/* -------- The Order Selector -------- */
-
-// Allows the cashier to create / delete and
-// switch between orders.
-
-var OrderSelectorWidget = PosBaseWidget.extend({
- template: 'OrderSelectorWidget',
- init: function(parent, options) {
- this._super(parent, options);
- this.pos.get('orders').bind('add remove change',this.renderElement,this);
- this.pos.bind('change:selectedOrder',this.renderElement,this);
- },
- get_order_by_uid: function(uid) {
- var orders = this.pos.get_order_list();
- for (var i = 0; i < orders.length; i++) {
- if (orders[i].uid === uid) {
- return orders[i];
- }
- }
- return undefined;
- },
- order_click_handler: function(event,$el) {
- var order = this.get_order_by_uid($el.data('uid'));
- if (order) {
- this.pos.set_order(order);
- }
- },
- neworder_click_handler: function(event, $el) {
- this.pos.add_new_order();
- },
- deleteorder_click_handler: function(event, $el) {
- var self = this;
- var order = this.pos.get_order();
- if (!order) {
- return;
- } else if ( !order.is_empty() ){
- this.gui.show_popup('confirm',{
- 'title': _t('Destroy Current Order ?'),
- 'body': _t('You will lose any data associated with the current order'),
- confirm: function(){
- self.pos.delete_current_order();
- },
- });
- } else {
- this.pos.delete_current_order();
- }
- },
- renderElement: function(){
- var self = this;
- this._super();
- this.$('.order-button.select-order').click(function(event){
- self.order_click_handler(event,$(this));
- });
- this.$('.neworder-button').click(function(event){
- self.neworder_click_handler(event,$(this));
- });
- this.$('.deleteorder-button').click(function(event){
- self.deleteorder_click_handler(event,$(this));
- });
- },
-});
-
-/* ------- The User Name Widget ------- */
-
-// Displays the current cashier's name
-
-var UsernameWidget = PosBaseWidget.extend({
- template: 'UsernameWidget',
- init: function(parent, options){
- options = options || {};
- this._super(parent,options);
- },
- get_name: function(){
- var user = this.pos.get_cashier();
- if(user){
- return user.name;
- }else{
- return "";
- }
- },
-});
-
-/* -------- The Header Button --------- */
-
-// Used to quickly add buttons with simple
-// labels and actions to the point of sale
-// header.
-
-var HeaderButtonWidget = PosBaseWidget.extend({
- template: 'HeaderButtonWidget',
- init: function(parent, options){
- options = options || {};
- this._super(parent, options);
- this.action = options.action;
- this.label = options.label;
- this.button_class = options.button_class;
-
- },
- renderElement: function(){
- var self = this;
- this._super();
- if(this.action){
- this.$el.click(function(){
- self.action();
- });
- }
- },
- show: function() { this.$el.removeClass('oe_hidden'); },
- hide: function() { this.$el.addClass('oe_hidden'); },
-});
-
-/* --------- The Debug Widget --------- */
-
-// The debug widget lets the user control
-// and monitor the hardware and software status
-// without the use of the proxy, or to access
-// the raw locally stored db values, useful
-// for debugging
-
-var DebugWidget = PosBaseWidget.extend({
- template: "DebugWidget",
- eans:{
- admin_badge: '0410100000006',
- client_badge: '0420200000004',
- invalid_ean: '1232456',
- soda_33cl: '5449000000996',
- oranges_kg: '2100002031410',
- lemon_price: '2301000001560',
- unknown_product: '9900000000004',
- },
- events:[
- 'open_cashbox',
- 'print_receipt',
- 'scale_read',
- ],
- init: function(parent,options){
- this._super(parent,options);
- var self = this;
-
- // for dragging the debug widget around
- this.dragging = false;
- this.dragpos = {x:0, y:0};
-
- function eventpos(event){
- if(event.touches && event.touches[0]){
- return {x: event.touches[0].screenX, y: event.touches[0].screenY};
- }else{
- return {x: event.screenX, y: event.screenY};
- }
- }
-
- this.dragend_handler = function(event){
- self.dragging = false;
- };
- this.dragstart_handler = function(event){
- self.dragging = true;
- self.dragpos = eventpos(event);
- };
- this.dragmove_handler = function(event){
- if(self.dragging){
- var top = this.offsetTop;
- var left = this.offsetLeft;
- var pos = eventpos(event);
- var dx = pos.x - self.dragpos.x;
- var dy = pos.y - self.dragpos.y;
-
- self.dragpos = pos;
-
- this.style.right = 'auto';
- this.style.bottom = 'auto';
- this.style.left = left + dx + 'px';
- this.style.top = top + dy + 'px';
- }
- event.preventDefault();
- event.stopPropagation();
- };
- },
- show: function() {
- this.$el.css({opacity:0});
- this.$el.removeClass('oe_hidden');
- this.$el.animate({opacity:1},250,'swing');
- },
- hide: function() {
- var self = this;
- this.$el.animate({opacity:0,},250,'swing',function(){
- self.$el.addClass('oe_hidden');
- });
- },
- start: function(){
- var self = this;
-
- if (this.pos.debug) {
- this.show();
- }
-
- this.el.addEventListener('mouseleave', this.dragend_handler);
- this.el.addEventListener('mouseup', this.dragend_handler);
- this.el.addEventListener('touchend', this.dragend_handler);
- this.el.addEventListener('touchcancel',this.dragend_handler);
- this.el.addEventListener('mousedown', this.dragstart_handler);
- this.el.addEventListener('touchstart', this.dragstart_handler);
- this.el.addEventListener('mousemove', this.dragmove_handler);
- this.el.addEventListener('touchmove', this.dragmove_handler);
-
- this.$('.toggle').click(function(){
- self.hide();
- });
- this.$('.button.set_weight').click(function(){
- var kg = Number(self.$('input.weight').val());
- if(!isNaN(kg)){
- self.pos.proxy.debug_set_weight(kg);
- }
- });
- this.$('.button.reset_weight').click(function(){
- self.$('input.weight').val('');
- self.pos.proxy.debug_reset_weight();
- });
- this.$('.button.custom_ean').click(function(){
- var ean = self.pos.barcode_reader.barcode_parser.sanitize_ean(self.$('input.ean').val() || '0');
- self.$('input.ean').val(ean);
- self.pos.barcode_reader.scan(ean);
- });
- this.$('.button.barcode').click(function(){
- self.pos.barcode_reader.scan(self.$('input.ean').val());
- });
- this.$('.button.delete_orders').click(function(){
- self.gui.show_popup('confirm',{
- 'title': _t('Delete Paid Orders ?'),
- 'body': _t('This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.'),
- confirm: function(){
- self.pos.db.remove_all_orders();
- self.pos.set_synch('connected', 0);
- },
- });
- });
- this.$('.button.delete_unpaid_orders').click(function(){
- self.gui.show_popup('confirm',{
- 'title': _t('Delete Unpaid Orders ?'),
- 'body': _t('This operation will destroy all unpaid orders in the browser. You will lose all the unsaved data and exit the point of sale. This operation cannot be undone.'),
- confirm: function(){
- self.pos.db.remove_all_unpaid_orders();
- window.location = '/';
- },
- });
- });
-
- this.$('.button.export_unpaid_orders').click(function(){
- self.gui.prepare_download_link(
- self.pos.export_unpaid_orders(),
- _t("unpaid orders") + ' ' + moment().format('YYYY-MM-DD-HH-mm-ss') + '.json',
- ".export_unpaid_orders", ".download_unpaid_orders"
- );
- });
-
- this.$('.button.export_paid_orders').click(function() {
- self.gui.prepare_download_link(
- self.pos.export_paid_orders(),
- _t("paid orders") + ' ' + moment().format('YYYY-MM-DD-HH-mm-ss') + '.json',
- ".export_paid_orders", ".download_paid_orders"
- );
- });
-
- this.$('.button.display_refresh').click(function () {
- self.pos.proxy.message('display_refresh', {});
- });
-
- this.$('.button.import_orders input').on('change', function(event) {
- var file = event.target.files[0];
-
- if (file) {
- var reader = new FileReader();
-
- reader.onload = function(event) {
- var report = self.pos.import_orders(event.target.result);
- self.gui.show_popup('orderimport',{report:report});
- };
-
- reader.readAsText(file);
- }
- });
-
- _.each(this.events, function(name){
- self.pos.proxy.add_notification(name,function(){
- self.$('.event.'+name).stop().clearQueue().css({'background-color':'#6CD11D'});
- self.$('.event.'+name).animate({'background-color':'#1E1E1E'},2000);
- });
- });
- },
-});
-
-/* --------- The Status Widget -------- */
-
-// Base class for widgets that want to display
-// status in the point of sale header.
-
-var StatusWidget = PosBaseWidget.extend({
- status: ['connected','connecting','disconnected','warning','error'],
-
- set_status: function(status,msg){
- for(var i = 0; i < this.status.length; i++){
- this.$('.js_'+this.status[i]).addClass('oe_hidden');
- }
- this.$('.js_'+status).removeClass('oe_hidden');
-
- if(msg){
- this.$('.js_msg').removeClass('oe_hidden').html(msg);
- }else{
- this.$('.js_msg').addClass('oe_hidden').html('');
- }
- },
-});
-
-/* ------- Synch. Notifications ------- */
-
-// Displays if there are orders that could
-// not be submitted, and how many.
-
-var SynchNotificationWidget = StatusWidget.extend({
- template: 'SynchNotificationWidget',
- start: function(){
- var self = this;
- this.pos.bind('change:synch', function(pos,synch){
- self.set_status(synch.state, synch.pending);
- });
- this.$el.click(function(){
- self.pos.push_order(null,{'show_error':true});
- });
- },
-});
-
-/* --------- The Proxy Status --------- */
-
-// Displays the status of the hardware proxy
-// (connected, disconnected, errors ... )
-
-var ProxyStatusWidget = StatusWidget.extend({
- template: 'ProxyStatusWidget',
- set_smart_status: function(status){
- if(status.status === 'connected'){
- var warning = false;
- var msg = '';
- if(this.pos.config.iface_scan_via_proxy){
- var scanner = status.drivers.scanner ? status.drivers.scanner.status : false;
- if( scanner != 'connected' && scanner != 'connecting'){
- warning = true;
- msg += _t('Scanner');
- }
- }
- if( this.pos.config.iface_print_via_proxy ||
- this.pos.config.iface_cashdrawer ){
- var printer = status.drivers.printer ? status.drivers.printer.status : false;
- if (printer != 'connected' && printer != 'connecting') {
- warning = true;
- msg = msg ? msg + ' & ' : msg;
- msg += _t('Printer');
- }
- }
- if( this.pos.config.iface_electronic_scale ){
- var scale = status.drivers.scale ? status.drivers.scale.status : false;
- if( scale != 'connected' && scale != 'connecting' ){
- warning = true;
- msg = msg ? msg + ' & ' : msg;
- msg += _t('Scale');
- }
- }
-
- msg = msg ? msg + ' ' + _t('Offline') : msg;
- this.set_status(warning ? 'warning' : 'connected', msg);
- }else{
- this.set_status(status.status, status.msg || '');
- }
- },
- start: function(){
- var self = this;
-
- this.set_smart_status(this.pos.proxy.get('status'));
-
- this.pos.proxy.on('change:status',this,function(eh,status){ //FIXME remove duplicate changes
- self.set_smart_status(status.newValue);
- });
-
- this.$el.click(function(){
- self.pos.connect_to_proxy();
- });
- },
-});
-
-
-/* --------- The Sale Details --------- */
-
-/** Print an overview of todays sales.
- *
- * If the current cashier is a manager all sales of the day will be printed, else only the sales of the current
- * session will be printed.
- */
-var SaleDetailsButton = PosBaseWidget.extend({
- template: 'SaleDetailsButton',
- start: function(){
- var self = this;
- this.$el.click(function(){
- self.print_sale_details();
- });
- },
-
- /** Print an overview of todays sales.
- *
- * By default this will print all sales of the day for current PoS config.
- */
- print_sale_details: function () {
- var self = this;
- rpc.query({
- model: 'report.point_of_sale.report_saledetails',
- method: 'get_sale_details',
- args: [false, false, false, [this.pos.pos_session.id]],
- })
- .then(function(result){
- var env = {
- widget: new PosBaseWidget(self),
- company: self.pos.company,
- pos: self.pos,
- products: result.products,
- payments: result.payments,
- taxes: result.taxes,
- total_paid: result.total_paid,
- date: (new Date()).toLocaleString(),
- };
- var report = QWeb.render('SaleDetailsReport', env);
- self.pos.proxy.printer.print_receipt(report);
- });
- },
-});
-
-/* User interface for distant control over the Client display on the IoT Box */
-// The boolean posbox_supports_display (in devices.js) will allow interaction to the IoT Box on true, prevents it otherwise
-// We don't want the incompatible IoT Box to be flooded with 404 errors on arrival of our many requests as it triggers losses of connections altogether
-var ClientScreenWidget = PosBaseWidget.extend({
- template: 'ClientScreenWidget',
-
- change_status_display: function(status) {
- var msg = ''
- if (status === 'success') {
- this.$('.js_warning').addClass('oe_hidden');
- this.$('.js_disconnected').addClass('oe_hidden');
- this.$('.js_connected').removeClass('oe_hidden');
- } else if (status === 'warning') {
- this.$('.js_disconnected').addClass('oe_hidden');
- this.$('.js_connected').addClass('oe_hidden');
- this.$('.js_warning').removeClass('oe_hidden');
- msg = _t('Connected, Not Owned');
- } else {
- this.$('.js_warning').addClass('oe_hidden');
- this.$('.js_connected').addClass('oe_hidden');
- this.$('.js_disconnected').removeClass('oe_hidden');
- msg = _t('Disconnected')
- if (status === 'not_found') {
- msg = _t('Client Screen Unsupported. Please upgrade the IoT Box')
- }
- }
-
- this.$('.oe_customer_display_text').text(msg);
- },
-
- status_loop: function() {
- var self = this;
- function loop() {
- if (self.pos.proxy.posbox_supports_display) {
- self.pos.proxy.test_ownership_of_client_screen().then(
- function (data) {
- if (typeof data === 'string') {
- data = JSON.parse(data);
- }
- if (data.status === 'OWNER') {
- self.change_status_display('success');
- } else {
- self.change_status_display('warning');
- }
- setTimeout(loop, 3000);
- },
- function (err) {
- if (err.abort) {
- // Stop the loop
- return;
- }
- if (typeof err == "undefined") {
- self.change_status_display('failure');
- } else {
- self.change_status_display('not_found');
- self.pos.proxy.posbox_supports_display = false;
- }
- setTimeout(loop, 3000);
- }
- );
- }
- }
- loop();
- },
-
- start: function(){
- if (this.pos.config.iface_customer_facing_display) {
- this.show();
- var self = this;
- this.$el.click(function(){
- self.pos.render_html_for_customer_facing_display().then(function(rendered_html) {
- self.pos.proxy.take_ownership_over_client_screen(rendered_html).then(
- function(data) {
- if (typeof data === 'string') {
- data = JSON.parse(data);
- }
- if (data.status === 'success') {
- self.change_status_display('success');
- } else {
- self.change_status_display('warning');
- }
- if (!self.pos.proxy.posbox_supports_display) {
- self.pos.proxy.posbox_supports_display = true;
- self.status_loop();
- }
- },
-
- function(err) {
- if (typeof err == "undefined") {
- self.change_status_display('failure');
- } else {
- self.change_status_display('not_found');
- }
- });
- });
-
- });
- this.status_loop();
- } else {
- this.hide();
- }
- },
-});
-
-
-/*--------------------------------------*\
- | THE CHROME |
-\*======================================*/
-
-// The Chrome is the main widget that contains
-// all other widgets in the PointOfSale.
-//
-// It is the first object instanciated and the
-// starting point of the point of sale code.
-//
-// It is mainly composed of :
-// - a header, containing the list of orders
-// - a leftpane, containing the list of bought
-// products (orderlines)
-// - a rightpane, containing the screens
-// (see pos_screens.js)
-// - popups
-// - an onscreen keyboard
-// - .gui which controls the switching between
-// screens and the showing/closing of popups
-
-var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
- template: 'Chrome',
- init: function() {
- var self = this;
- this._super(arguments[0],{});
-
- this.started = new $.Deferred(); // resolves when DOM is online
- this.ready = new $.Deferred(); // resolves when the whole GUI has been loaded
-
- this.pos = new models.PosModel(this.getSession(), {chrome:this});
- this.gui = new gui.Gui({pos: this.pos, chrome: this});
- this.chrome = this; // So that chrome's childs have chrome set automatically
- this.pos.gui = this.gui;
-
- this.logo_click_time = 0;
- this.logo_click_count = 0;
-
- this.previous_touch_y_coordinate = -1;
-
- this.widget = {}; // contains references to subwidgets instances
-
- this.cleanup_dom();
- this.pos.ready.then(function(){
- self.build_chrome();
- self.build_widgets();
- self.disable_rubberbanding();
- self.disable_backpace_back();
- self.ready.resolve();
- self.loading_hide();
- self.replace_crashmanager();
- self.pos.push_order();
- }).guardedCatch(function (err) { // error when loading models data from the backend
- self.loading_error(err);
- });
- },
-
- cleanup_dom: function() {
- // remove default webclient handlers that induce click delay
- $(document).off();
- $(window).off();
- $('html').off();
- $('body').off();
- // The above lines removed the bindings, but we really need them for the barcode
- BarcodeEvents.start();
- },
-
- build_chrome: function() {
- var self = this;
-
- if ($.browser.chrome) {
- var chrome_version = $.browser.version.split('.')[0];
- if (parseInt(chrome_version, 10) >= 50) {
- ajax.loadCSS('/point_of_sale/static/src/css/chrome50.css');
- }
- }
-
- this.renderElement();
-
- this.$('.pos-logo').click(function(){
- self.click_logo();
- });
-
- if(this.pos.config.iface_big_scrollbars){
- this.$el.addClass('big-scrollbars');
- }
- },
-
- // displays a system error with the error-traceback
- // popup.
- show_error: function(error) {
- this.gui.show_popup('error-traceback',{
- 'title': error.message,
- 'body': error.message + '\n' + error.data.debug + '\n',
- });
- },
-
- // replaces the error handling of the existing crashmanager which
- // uses jquery dialog to display the error, to use the pos popup
- // instead
- replace_crashmanager: function() {
- var self = this;
- CrashManager.include({
- show_error: function(error) {
- if (self.gui) {
- self.show_error(error);
- } else {
- this._super(error);
- }
- },
- });
- },
-
- click_logo: function() {
- if (this.pos.debug) {
- this.widget.debug.show();
- } else {
- var self = this;
- var time = (new Date()).getTime();
- var delay = 500;
- if (this.logo_click_time + 500 < time) {
- this.logo_click_time = time;
- this.logo_click_count = 1;
- } else {
- this.logo_click_time = time;
- this.logo_click_count += 1;
- if (this.logo_click_count >= 6) {
- this.logo_click_count = 0;
- this.gui.sudo().then(function(){
- self.widget.debug.show();
- });
- }
- }
- }
- },
-
- _scrollable: function(element, scrolling_down){
- var $element = $(element);
- var scrollable = true;
-
- if (! scrolling_down && $element.scrollTop() <= 0) {
- scrollable = false;
- } else if (scrolling_down && $element.scrollTop() + $element.height() >= element.scrollHeight) {
- scrollable = false;
- }
-
- return scrollable;
- },
-
- disable_rubberbanding: function(){
- var self = this;
-
- document.body.addEventListener('touchstart', function(event){
- self.previous_touch_y_coordinate = event.touches[0].clientY;
- });
-
- // prevent the pos body from being scrollable.
- document.body.addEventListener('touchmove',function(event){
- var node = event.target;
- var current_touch_y_coordinate = event.touches[0].clientY;
- var scrolling_down;
-
- if (current_touch_y_coordinate < self.previous_touch_y_coordinate) {
- scrolling_down = true;
- } else {
- scrolling_down = false;
- }
-
- while(node){
- if(node.classList && node.classList.contains('touch-scrollable') && self._scrollable(node, scrolling_down)){
- return;
- }
- node = node.parentNode;
- }
- event.preventDefault();
- });
- },
-
- // prevent backspace from performing a 'back' navigation
- disable_backpace_back: function() {
- $(document).on("keydown", function (e) {
- if (e.which === 8 && !$(e.target).is("input, textarea")) {
- e.preventDefault();
- }
- });
- },
-
- loading_error: function(err){
- var self = this;
-
- var title = err.message;
- var body = err.stack;
-
- if(err.message === 'XmlHttpRequestError '){
- title = 'Network Failure (XmlHttpRequestError)';
- body = 'The Point of Sale could not be loaded due to a network problem.\n Please check your internet connection.';
- }else if(err.code === 200){
- title = err.data.message;
- body = err.data.debug;
- }
-
- if( typeof body !== 'string' ){
- body = 'Traceback not available.';
- }
-
- var popup = $(QWeb.render('ErrorTracebackPopupWidget',{
- widget: { options: {title: title , body: body }},
- }));
-
- popup.find('.button').click(function(){
- self.gui.close();
- });
-
- popup.css({ zindex: 9001 });
-
- popup.appendTo(this.$el);
- },
- loading_progress: function(fac){
- this.$('.loader .loader-feedback').removeClass('oe_hidden');
- this.$('.loader .progress').removeClass('oe_hidden').css({'width': ''+Math.floor(fac*100)+'%'});
- },
- loading_message: function(msg,progress){
- this.$('.loader .loader-feedback').removeClass('oe_hidden');
- this.$('.loader .message').text(msg);
- if (typeof progress !== 'undefined') {
- this.loading_progress(progress);
- } else {
- this.$('.loader .progress').addClass('oe_hidden');
- }
- },
- loading_skip: function(callback){
- if(callback){
- this.$('.loader .loader-feedback').removeClass('oe_hidden');
- this.$('.loader .button.skip').removeClass('oe_hidden');
- this.$('.loader .button.skip').off('click');
- this.$('.loader .button.skip').click(callback);
- }else{
- this.$('.loader .button.skip').addClass('oe_hidden');
- }
- },
- loading_hide: function(){
- var self = this;
- this.$('.loader').animate({opacity:0},1500,'swing',function(){self.$('.loader').addClass('oe_hidden');});
- },
- loading_show: function(){
- this.$('.loader').removeClass('oe_hidden').animate({opacity:1},150,'swing');
- },
- widgets: [
- {
- 'name': 'order_selector',
- 'widget': OrderSelectorWidget,
- 'replace': '.placeholder-OrderSelectorWidget',
- },{
- 'name': 'sale_details',
- 'widget': SaleDetailsButton,
- 'append': '.pos-rightheader',
- 'condition': function(){ return this.pos.proxy.printer; },
- },{
- 'name': 'proxy_status',
- 'widget': ProxyStatusWidget,
- 'append': '.pos-rightheader',
- 'condition': function(){ return this.pos.config.use_proxy; },
- },{
- 'name': 'screen_status',
- 'widget': ClientScreenWidget,
- 'append': '.pos-rightheader',
- 'condition': function(){ return this.pos.config.use_proxy; },
- },{
- 'name': 'notification',
- 'widget': SynchNotificationWidget,
- 'append': '.pos-rightheader',
- },{
- 'name': 'close_button',
- 'widget': HeaderButtonWidget,
- 'append': '.pos-rightheader',
- 'args': {
- label: _t('Close'),
- action: function(){
- this.$el.addClass('close_button');
- var self = this;
- if (!this.confirmed) {
- this.$el.addClass('confirm');
- this.$el.text(_t('Confirm'));
- this.confirmed = setTimeout(function(){
- self.$el.removeClass('confirm');
- self.$el.text(_t('Close'));
- self.confirmed = false;
- },2000);
- } else {
- clearTimeout(this.confirmed);
- this.gui.close();
- }
- },
- }
- },{
- 'name': 'username',
- 'widget': UsernameWidget,
- 'replace': '.placeholder-UsernameWidget',
- },{
- 'name': 'keyboard',
- 'widget': keyboard.OnscreenKeyboardWidget,
- 'replace': '.placeholder-OnscreenKeyboardWidget',
- },{
- 'name': 'debug',
- 'widget': DebugWidget,
- 'append': '.pos-content',
- },
- ],
-
- load_widgets: function(widgets) {
- for (var i = 0; i < widgets.length; i++) {
- var widget = widgets[i];
- if ( !widget.condition || widget.condition.call(this) ) {
- var args = typeof widget.args === 'function' ? widget.args(this) : widget.args;
- var w = new widget.widget(this, args || {});
- if (widget.replace) {
- w.replace(this.$(widget.replace));
- } else if (widget.append) {
- w.appendTo(this.$(widget.append));
- } else if (widget.prepend) {
- w.prependTo(this.$(widget.prepend));
- } else {
- w.appendTo(this.$el);
- }
- this.widget[widget.name] = w;
- }
- }
- },
-
- // This method instantiates all the screens, widgets, etc.
- build_widgets: function() {
- var self = this;
- this.load_widgets(this.widgets);
-
- this.screens = {};
- var classe;
- for (var i = 0; i < this.gui.screen_classes.length; i++) {
- classe = this.gui.screen_classes[i];
- if (!classe.condition || classe.condition.call(this)) {
- var screen = new classe.widget(this,{});
- screen.appendTo(this.$('.screens'));
- this.screens[classe.name] = screen;
- this.gui.add_screen(classe.name, screen);
- }
- }
-
- this.popups = {};
- _.forEach(this.gui.popup_classes, function (classe) {
- if (!classe.condition || classe.condition.call(self)) {
- var popup = new classe.widget(self,{});
- popup.appendTo(self.$('.popups')).then(function () {
- self.popups[classe.name] = popup;
- self.gui.add_popup(classe.name, popup);
- });
- }
- });
-
- this.gui.set_startup_screen('products');
- this.gui.set_default_screen('products');
-
- },
-
- destroy: function() {
- this.pos.destroy();
- this._super();
- }
-});
-
-return {
- Chrome: Chrome,
- DebugWidget: DebugWidget,
- HeaderButtonWidget: HeaderButtonWidget,
- OrderSelectorWidget: OrderSelectorWidget,
- ProxyStatusWidget: ProxyStatusWidget,
- SaleDetailsButton: SaleDetailsButton,
- ClientScreenWidget: ClientScreenWidget,
- StatusWidget: StatusWidget,
- SynchNotificationWidget: SynchNotificationWidget,
- UsernameWidget: UsernameWidget,
-};
-});
diff --git a/addons/point_of_sale/static/src/js/custom_hooks.js b/addons/point_of_sale/static/src/js/custom_hooks.js
new file mode 100644
index 00000000000..bedad8fbbad
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/custom_hooks.js
@@ -0,0 +1,141 @@
+odoo.define('point_of_sale.custom_hooks', function (require) {
+ 'use strict';
+
+ const { Component } = owl;
+ const { onMounted, onPatched, onWillUnmount } = owl.hooks;
+
+ /**
+ * Introduce error handlers in the component.
+ *
+ * IMPROVEMENT: This is a terrible hook. There could be a better way to handle
+ * the error when the order failed to sync.
+ */
+ function useErrorHandlers() {
+ const component = Component.current;
+
+ component._handlePushOrderError = async function (error) {
+ // This error handler receives `error` equivalent to `error.message` of the rpc error.
+ if (error.message === 'Backend Invoice') {
+ await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Please print the invoice from the backend'),
+ body:
+ this.env._t(
+ 'The order has been synchronized earlier. Please make the invoice from the backend for the order: '
+ ) + error.data.order.name,
+ });
+ } else if (error.code < 0) {
+ // XmlHttpRequest Errors
+ const title = this.env._t('Unable to sync order');
+ const body = this.env._t(
+ 'Check the internet connection then try to sync again by clicking on the red wifi button (upper right of the screen).'
+ );
+ await this.showPopup('OfflineErrorPopup', { title, body });
+ } else if (error.code === 200) {
+ // OpenERP Server Errors
+ await this.showPopup('ErrorTracebackPopup', {
+ title: error.data.message || this.env._t('Server Error'),
+ body:
+ error.data.debug ||
+ this.env._t('The server encountered an error while receiving your order.'),
+ });
+ } else {
+ // ???
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Unknown Error'),
+ body: this.env._t(
+ 'The order could not be sent to the server due to an unknown error'
+ ),
+ });
+ }
+ };
+ }
+
+ function useAutoFocusToLast() {
+ const current = Component.current;
+ let target = null;
+ function autofocus() {
+ const prevTarget = target;
+ const allInputs = current.el.querySelectorAll('input');
+ target = allInputs[allInputs.length - 1];
+ if (target && target !== prevTarget) {
+ target.focus();
+ target.selectionStart = target.selectionEnd = target.value.length;
+ }
+ }
+ onMounted(autofocus);
+ onPatched(autofocus);
+ }
+
+ /**
+ * Use this hook when you want to do something on previously selected and
+ * newly selected order when the order changes.
+ *
+ * Normally, a component is rendered then the current order is changed. When
+ * this happens, we want to rerender the component because the new information
+ * should be reflected in the screen. Additionally, we might want to remove listeners
+ * to the previous order and attach listeners to the new one. This hook is
+ * perfect for the described situation.
+ *
+ * Internally, this hook performs the following:
+ * 1. call newOrderCB on mounted
+ * 2. listen to order changes and perform the following sequence:
+ * - call prevOrderCB(prevOrder)
+ * - call newOrderCB(newOrder)
+ * 3. call prevOrderCB on willUnmount
+ *
+ * @param {Function} prevOrderCB apply this callback on the previous order
+ * @param {Function} newOrderCB apply this callback on the new order
+ */
+ function onChangeOrder(prevOrderCB, newOrderCB) {
+ const current = Component.current;
+ prevOrderCB = prevOrderCB ? prevOrderCB.bind(current) : () => {};
+ newOrderCB = newOrderCB ? newOrderCB.bind(current) : () => {};
+ onMounted(() => {
+ current.env.pos.on(
+ 'change:selectedOrder',
+ async (pos, newOrder) => {
+ await prevOrderCB(pos.previous('selectedOrder'));
+ await newOrderCB(newOrder);
+ },
+ current
+ );
+ newOrderCB(current.env.pos.get_order());
+ });
+ onWillUnmount(() => {
+ current.env.pos.off('change:selectedOrder', null, current);
+ prevOrderCB(current.env.pos.get_order());
+ });
+ }
+
+ function useBarcodeReader(callbackMap, exclusive = false) {
+ const current = Component.current;
+ const barcodeReader = current.env.pos.barcode_reader;
+ for (let [key, callback] of Object.entries(callbackMap)) {
+ callbackMap[key] = callback.bind(current);
+ }
+ onMounted(() => {
+ if (barcodeReader) {
+ for (let key in callbackMap) {
+ if (exclusive) {
+ barcodeReader.set_exclusive_callback(key, callbackMap[key]);
+ } else {
+ barcodeReader.set_action_callback(key, callbackMap[key]);
+ }
+ }
+ }
+ });
+ onWillUnmount(() => {
+ if (barcodeReader) {
+ for (let key in callbackMap) {
+ if (exclusive) {
+ barcodeReader.remove_exclusive_callback(key, callbackMap[key]);
+ } else {
+ barcodeReader.remove_action_callback(key, callbackMap[key]);
+ }
+ }
+ }
+ });
+ }
+
+ return { useErrorHandlers, useAutoFocusToLast, onChangeOrder, useBarcodeReader };
+});
diff --git a/addons/point_of_sale/static/src/js/db.js b/addons/point_of_sale/static/src/js/db.js
index 6c2b71fab16..31fea30b1d7 100644
--- a/addons/point_of_sale/static/src/js/db.js
+++ b/addons/point_of_sale/static/src/js/db.js
@@ -180,6 +180,7 @@ var PosDB = core.Class.extend({
}
for(var i = 0, len = products.length; i < len; i++){
var product = products[i];
+ if (product.id in this.product_by_id) continue;
var search_string = utils.unaccent(this._product_search_string(product));
var categ_id = product.pos_categ_id ? product.pos_categ_id[0] : this.root_category_id;
product.product_tmpl_id = product.product_tmpl_id[0];
diff --git a/addons/point_of_sale/static/src/js/debug_manager.js b/addons/point_of_sale/static/src/js/debug_manager.js
new file mode 100644
index 00000000000..bd0d45c5472
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/debug_manager.js
@@ -0,0 +1,20 @@
+odoo.define('point_of_sale.DebugManager.Backend', function(require) {
+ 'use strict';
+
+ const { _t } = require('web.core');
+ const DebugManager = require('web.DebugManager.Backend');
+
+ DebugManager.include({
+ /**
+ * Runs the JS (desktop) tests
+ */
+ perform_pos_js_tests() {
+ this.do_action({
+ name: _t('JS Tests'),
+ target: 'new',
+ type: 'ir.actions.act_url',
+ url: '/pos/web/tests?mod=*',
+ });
+ },
+ });
+});
diff --git a/addons/point_of_sale/static/src/js/gui.js b/addons/point_of_sale/static/src/js/gui.js
deleted file mode 100644
index 11baff32073..00000000000
--- a/addons/point_of_sale/static/src/js/gui.js
+++ /dev/null
@@ -1,464 +0,0 @@
-odoo.define('point_of_sale.gui', function (require) {
-"use strict";
-// this file contains the Gui, which is the pos 'controller'.
-// It contains high level methods to manipulate the interface
-// such as changing between screens, creating popups, etc.
-//
-// it is available to all pos objects trough the '.gui' field.
-
-var core = require('web.core');
-var field_utils = require('web.field_utils');
-var session = require('web.session');
-
-var _t = core._t;
-
-var Gui = core.Class.extend({
- screen_classes: [],
- popup_classes: [],
- init: function(options){
- var self = this;
- this.pos = options.pos;
- this.chrome = options.chrome;
- this.screen_instances = {};
- this.popup_instances = {};
- this.default_screen = null;
- this.startup_screen = null;
- this.current_popup = null;
- this.current_screen = null;
- this.show_sync_errors = true;
-
- this.chrome.ready.then(function(){
- self.close_other_tabs();
- self._show_first_screen();
- self.pos.bind('change:selectedOrder', function(){
- self.show_saved_screen(self.pos.get_order());
- });
- });
- },
-
- _show_first_screen: function() {
- var order = this.pos.get_order();
- if (order) {
- this.show_saved_screen(order);
- } else {
- this.show_screen(this.startup_screen);
- }
- },
-
- /* ---- Gui: SCREEN MANIPULATION ---- */
-
- // register a screen widget to the gui,
- // it must have been inserted into the dom.
- add_screen: function(name, screen){
- screen.hide();
- this.screen_instances[name] = screen;
- },
-
- // sets the screen that will be displayed
- // for new orders
- set_default_screen: function(name){
- this.default_screen = name;
- },
-
- // sets the screen that will be displayed
- // when no orders are present
- set_startup_screen: function(name) {
- this.startup_screen = name;
- },
-
- // display the screen saved in an order,
- // called when the user changes the current order
- // no screen saved ? -> display default_screen
- // no order ? -> display startup_screen
- show_saved_screen: function(order,options) {
- options = options || {};
- this.close_popup();
- if (order) {
- this.show_screen(order.get_screen_data('screen') ||
- options.default_screen ||
- this.default_screen,
- null,'refresh');
- } else {
- this.show_screen(this.startup_screen);
- }
- },
-
- // display a screen.
- // If there is an order, the screen will be saved in the order
- // - params: used to load a screen with parameters, for
- // example loading a 'product_details' screen for a specific product.
- // - refresh: if you want the screen to cycle trough show / hide even
- // if you are already on the same screen.
- show_screen: function(screen_name,params,refresh,skip_close_popup) {
- var screen = this.screen_instances[screen_name];
- if (!screen) {
- console.error("ERROR: show_screen("+screen_name+") : screen not found");
- }
- if (!skip_close_popup){
- this.close_popup();
- }
- var order = this.pos.get_order();
- if (order) {
- var old_screen_name = order.get_screen_data('screen');
-
- order.set_screen_data('screen',screen_name);
-
- if(params){
- order.set_screen_data('params',params);
- }
-
- if( screen_name !== old_screen_name ){
- order.set_screen_data('previous-screen',old_screen_name);
- }
- }
-
- if (refresh || screen !== this.current_screen) {
- if (this.current_screen) {
- this.current_screen.close();
- this.current_screen.hide();
- }
- this.current_screen = screen;
- this.current_screen.show(refresh);
- }
- },
-
- // returns the current screen.
- get_current_screen: function() {
- return this.pos.get_order() ? ( this.pos.get_order().get_screen_data('screen') || this.default_screen ) : this.startup_screen;
- },
-
- // goes to the previous screen (as specified in the order). The history only
- // goes 1 deep ...
- back: function() {
- var previous = this.pos.get_order().get_screen_data('previous-screen');
- if (previous) {
- this.show_screen(previous);
- }
- },
-
- // returns the parameter specified when this screen was displayed
- get_current_screen_param: function(param) {
- if (this.pos.get_order()) {
- var params = this.pos.get_order().get_screen_data('params');
- return params ? params[param] : undefined;
- } else {
- return undefined;
- }
- },
-
- /* ---- Gui: POPUP MANIPULATION ---- */
-
- // registers a new popup in the GUI.
- // the popup must have been previously inserted
- // into the dom.
- add_popup: function(name, popup) {
- popup.hide();
- this.popup_instances[name] = popup;
- },
-
- // displays a popup. Popup do not stack,
- // are not remembered by the order, and are
- // closed by screen changes or new popups.
- show_popup: function(name,options) {
- if (this.current_popup) {
- this.close_popup();
- }
- this.current_popup = this.popup_instances[name];
- return this.current_popup.show(options);
- },
- show_sync_error_popup: function() {
- if (this.show_sync_errors) {
- this.show_popup('error-sync',{
- 'title':_t('Changes could not be saved'),
- 'body': _t('You must be connected to the internet to save your changes.\n\n' +
- 'Orders that where not synced before will be synced next time you close an order while connected to the internet or when you close the session.'),
- });
- }
- },
-
- // close the current popup.
- close_popup: function() {
- if (this.current_popup) {
- this.current_popup.close();
- this.current_popup.hide();
- this.current_popup = null;
- }
- },
-
- // is there an active popup ?
- has_popup: function() {
- return !!this.current_popup;
- },
-
- /* ---- Gui: INTER TAB COMM ---- */
-
- // This sets up automatic pos exit when open in
- // another tab.
- close_other_tabs: function() {
- var self = this;
-
- // avoid closing itself
- var now = Date.now();
-
- localStorage['message'] = '';
- localStorage['message'] = JSON.stringify({
- 'message':'close_tabs',
- 'config': this.pos.config.id,
- 'window_uid': now,
- });
-
- // storage events are (most of the time) triggered only when the
- // localstorage is updated in a different tab.
- // some browsers (e.g. IE) does trigger an event in the same tab
- // This may be a browser bug or a different interpretation of the HTML spec
- // cf https://connect.microsoft.com/IE/feedback/details/774798/localstorage-event-fired-in-source-window
- // Use window_uid parameter to exclude the current window
- window.addEventListener("storage", function(event) {
- var msg = event.data;
-
- if ( event.key === 'message' && event.newValue) {
-
- var msg = JSON.parse(event.newValue);
- if ( msg.message === 'close_tabs' &&
- msg.config == self.pos.config.id &&
- msg.window_uid != now) {
-
- console.info('POS / Session opened in another window. EXITING POS');
- self._close();
- }
- }
-
- }, false);
- },
-
- /* ---- Gui: ACCESS CONTROL ---- */
-
- // A Generic UI that allow to select a user from a list.
- // It returns a promise that resolves with the selected user
- // upon success. Several options are available :
- // - security: passwords will be asked
- // - only_managers: restricts the list to managers
- // - current_user: password will not be asked if this
- // user is selected.
- // - title: The title of the employee selection list.
-
-
- // checks if the current user (or the user provided) has manager
- // access rights. If not, a popup is shown allowing the user to
- // temporarily login as an administrator.
- // This method returns a promise, that succeeds with the
- // manager user when the login is successfull.
- sudo: function(user){
- user = user || this.pos.get_cashier();
-
- if (user.role === 'manager') {
- return Promise.resolve(user);
- } else {
- return this.select_employee({
- security: true,
- only_managers: true,
- title: _t('Login as a Manager'),
- });
- }
- },
-
- /* ---- Gui: CLOSING THE POINT OF SALE ---- */
-
- close: function() {
- var self = this;
- var pending = this.pos.db.get_orders().length;
-
- if (!pending) {
- this._close();
- } else {
- var always = function () {
- var pending = self.pos.db.get_orders().length;
- if (!pending) {
- self._close();
- } else {
- var reason = self.pos.get('failed') ?
- _t('Some orders could not be submitted to '+
- 'the server due to configuration errors. '+
- 'You can exit the Point of Sale, but do '+
- 'not close the session before the issue '+
- 'has been resolved.') :
- _t('Some orders could not be submitted to '+
- 'the server due to internet connection issues. '+
- 'You can exit the Point of Sale, but do '+
- 'not close the session before the issue '+
- 'has been resolved.');
-
- self.show_popup('confirm', {
- 'title': _t('Offline Orders'),
- 'body': reason,
- 'confirm': function() {
- self._close();
- },
- });
- }
- };
- this.pos.push_order().then(always, always);
- }
- },
-
- _close: function() {
- var self = this;
- this.chrome.loading_show();
- this.chrome.loading_message(_t('Closing ...'));
-
- this.pos.push_order().then(function(){
- window.location = "/web#action=point_of_sale.action_client_pos_menu";
- });
- },
-
- /* ---- Gui: SOUND ---- */
-
- play_sound: function(sound) {
- var src = '';
- if (sound === 'error') {
- src = "/point_of_sale/static/src/sounds/error.wav";
- } else if (sound === 'bell') {
- src = "/point_of_sale/static/src/sounds/bell.wav";
- } else {
- console.error('Unknown sound: ',sound);
- return;
- }
- $('body').append('<audio src="'+src+'" autoplay="true"></audio>');
- },
-
- /* ---- Gui: FILE I/O ---- */
-
- // This will make the browser download 'contents' as a
- // file named 'name'
- // if 'contents' is not a string, it is converted into
- // a JSON representation of the contents.
-
-
- // TODO: remove me in master: deprecated in favor of prepare_download_link
- // this method is kept for backward compatibility but is likely not going
- // to work as many browsers do to not accept fake click events on links
- download_file: function(contents, name) {
- href_params = this.prepare_file_blob(contents,name);
- var evt = document.createEvent("HTMLEvents");
- evt.initEvent("click");
-
- $("<a>",href_params).get(0).dispatchEvent(evt);
-
- },
-
- prepare_download_link: function(contents, filename, src, target) {
- var href_params = this.prepare_file_blob(contents, filename);
-
- $(target).parent().attr(href_params);
- $(src).addClass('oe_hidden');
- $(target).removeClass('oe_hidden');
-
- // hide again after click
- $(target).click(function() {
- $(src).removeClass('oe_hidden');
- $(this).addClass('oe_hidden');
- });
- },
-
- prepare_file_blob: function(contents, name) {
- var URL = window.URL || window.webkitURL;
-
- if (typeof contents !== 'string') {
- contents = JSON.stringify(contents,null,2);
- }
-
- var blob = new Blob([contents]);
-
- return {download: name || 'document.txt',
- href: URL.createObjectURL(blob),}
- },
-
- /* ---- Gui: EMAILS ---- */
-
- // This will launch the user's email software
- // with a new email with the address, subject and body
- // prefilled.
-
- send_email: function(address, subject, body) {
- window.open("mailto:" + address +
- "?subject=" + (subject ? window.encodeURIComponent(subject) : '') +
- "&body=" + (body ? window.encodeURIComponent(body) : ''));
- },
-
- /* ---- Gui: KEYBOARD INPUT ---- */
-
- // This is a helper to handle numpad keyboard input.
- // - buffer: an empty or number string
- // - input: '[0-9],'+','-','.','CLEAR','BACKSPACE'
- // - options: 'firstinput' -> will clear buffer if
- // input is '[0-9]' or '.'
- // returns the new buffer containing the modifications
- // (the original is not touched)
- numpad_input: function(buffer, input, options) {
- var newbuf = buffer.slice(0);
- options = options || {};
- var newbuf_float = newbuf === '-' ? newbuf : field_utils.parse.float(newbuf);
- var decimal_point = _t.database.parameters.decimal_point;
-
- if (input === decimal_point) {
- if (options.firstinput) {
- newbuf = "0.";
- }else if (!newbuf.length || newbuf === '-') {
- newbuf += "0.";
- } else if (newbuf.indexOf(decimal_point) < 0){
- newbuf = newbuf + decimal_point;
- }
- } else if (input === 'CLEAR') {
- newbuf = "";
- } else if (input === 'BACKSPACE') {
- newbuf = newbuf.substring(0,newbuf.length - 1);
- } else if (input === '+') {
- if ( newbuf[0] === '-' ) {
- newbuf = newbuf.substring(1,newbuf.length);
- }
- } else if (input === '-') {
- if (options.firstinput) {
- newbuf = '-0';
- } else if ( newbuf[0] === '-' ) {
- newbuf = newbuf.substring(1,newbuf.length);
- } else {
- newbuf = '-' + newbuf;
- }
- } else if (input[0] === '+' && !isNaN(parseFloat(input))) {
- newbuf = this.chrome.format_currency_no_symbol(newbuf_float + parseFloat(input));
- } else if (!isNaN(parseInt(input))) {
- if (options.firstinput) {
- newbuf = '' + input;
- } else {
- newbuf += input;
- }
- }
- if (newbuf === "-") {
- newbuf = "";
- }
-
- // End of input buffer at 12 characters.
- if (newbuf.length > buffer.length && newbuf.length > 12) {
- this.play_sound('bell');
- return buffer.slice(0);
- }
-
- return newbuf;
- },
-});
-
-var define_screen = function (classe) {
- Gui.prototype.screen_classes.push(classe);
-};
-
-var define_popup = function (classe) {
- Gui.prototype.popup_classes.push(classe);
-};
-
-return {
- Gui: Gui,
- define_screen: define_screen,
- define_popup: define_popup,
-};
-
-});
diff --git a/addons/point_of_sale/static/src/js/main.js b/addons/point_of_sale/static/src/js/main.js
index 1af93ff17c7..e174717723a 100644
--- a/addons/point_of_sale/static/src/js/main.js
+++ b/addons/point_of_sale/static/src/js/main.js
@@ -1,10 +1,29 @@
+odoo.define('web.web_client', function (require) {
+ 'use strict';
-odoo.define('point_of_sale.main', function (require) {
-"use strict";
+ const env = require('web.env');
+ const WebClient = require('web.AbstractWebClient');
+ const Chrome = require('point_of_sale.Chrome');
+ const Registries = require('point_of_sale.Registries');
+ const { configureGui } = require('point_of_sale.Gui');
-var chrome = require('point_of_sale.chrome');
-var core = require('web.core');
+ owl.config.mode = env.isDebug() ? 'dev' : 'prod';
+ owl.Component.env = env;
-core.action_registry.add('pos.ui', chrome.Chrome);
+ async function startPosApp(webClient) {
+ Registries.Component.freeze();
+ await env.session.is_bound;
+ env.qweb.addTemplates(env.session.owlTemplates);
+ await owl.utils.whenReady();
+ await webClient.setElement(document.body);
+ await webClient.start();
+ const chrome = new (Registries.Component.get(Chrome))(null, { webClient });
+ await chrome.mount(document.querySelector('.o_action_manager'));
+ await chrome.start();
+ configureGui({ component: chrome });
+ }
+ const webClient = new WebClient();
+ startPosApp(webClient);
+ return webClient;
});
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index a318641fe92..e4b7ee752c0 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -1,7 +1,6 @@
odoo.define('point_of_sale.models', function (require) {
"use strict";
-var ajax = require('web.ajax');
var BarcodeParser = require('barcodes.BarcodeParser');
var BarcodeReader = require('point_of_sale.BarcodeReader');
var PosDB = require('point_of_sale.DB');
@@ -10,8 +9,6 @@ var concurrency = require('web.concurrency');
var config = require('web.config');
var core = require('web.core');
var field_utils = require('web.field_utils');
-var rpc = require('web.rpc');
-var session = require('web.session');
var time = require('web.time');
var utils = require('web.utils');
@@ -34,12 +31,17 @@ var exports = {};
// 'pos' and is available to all widgets extending PosWidget.
exports.PosModel = Backbone.Model.extend({
- initialize: function(session, attributes) {
+ initialize: function(attributes) {
Backbone.Model.prototype.initialize.call(this, attributes);
var self = this;
this.flush_mutex = new Mutex(); // used to make sure the orders are sent to the server once at time
- this.chrome = attributes.chrome;
- this.gui = attributes.gui;
+
+ this.rpc = this.get('rpc');
+ this.session = this.get('session');
+ this.do_action = this.get('do_action');
+ this.setLoadingMessage = this.get('setLoadingMessage');
+ this.setLoadingProgress = this.get('setLoadingProgress');
+ this.showLoadingSkip = this.get('showLoadingSkip');
this.proxy = new devices.ProxyDevice(this); // used to communicate to the hardware devices via a local proxy
this.barcode_reader = new BarcodeReader({'pos': this, proxy:this.proxy});
@@ -73,14 +75,15 @@ exports.PosModel = Backbone.Model.extend({
// these dynamic attributes can be watched for change by other models or widgets
this.set({
- 'synch': { state:'connected', pending:0 },
+ 'synch': { status:'connected', pending:0 },
'orders': new OrderCollection(),
'selectedOrder': null,
'selectedClient': null,
'cashier': null,
+ 'selectedCategoryId': null,
});
- this.get('orders').bind('remove', function(order,_unused_,options){
+ this.get('orders').on('remove', function(order,_unused_,options){
self.on_removed_order(order,options.index,options.reason);
});
@@ -89,8 +92,8 @@ exports.PosModel = Backbone.Model.extend({
var order = self.get_order();
this.set('selectedClient', order ? order.get_client() : null );
}
- this.get('orders').bind('add remove change', update_client, this);
- this.bind('change:selectedOrder', update_client, this);
+ this.get('orders').on('add remove change', update_client, this);
+ this.on('change:selectedOrder', update_client, this);
// We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched,
// Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched.
@@ -123,14 +126,14 @@ exports.PosModel = Backbone.Model.extend({
var self = this;
return new Promise(function (resolve, reject) {
self.barcode_reader.disconnect_from_proxy();
- self.chrome.loading_message(_t('Connecting to the IoT Box'), 0);
- self.chrome.loading_skip(function () {
+ self.setLoadingMessage(_t('Connecting to the IoT Box'), 0);
+ self.showLoadingSkip(function () {
self.proxy.stop_searching();
});
self.proxy.autoconnect({
force_ip: self.config.proxy_ip || undefined,
progress: function(prog){
- self.chrome.loading_progress(prog);
+ self.setLoadingProgress(prog);
},
}).then(
function () {
@@ -140,16 +143,20 @@ exports.PosModel = Backbone.Model.extend({
resolve();
},
function (statusText, url) {
- var show_loading_error = (self.gui.current_screen === null);
- resolve();
- if (show_loading_error && statusText == 'error' && window.location.protocol == 'https:') {
- self.gui.show_popup('alert', {
+ // this should reject so that it can be captured when we wait for pos.ready
+ // in the chrome component.
+ // then, if it got really rejected, we can show the error.
+ if (statusText == 'error' && window.location.protocol == 'https:') {
+ reject({
title: _t('HTTPS connection to IoT Box failed'),
body: _.str.sprintf(
_t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'),
url
),
+ popup: 'alert',
});
+ } else {
+ resolve();
}
}
);
@@ -166,7 +173,7 @@ exports.PosModel = Backbone.Model.extend({
{
label: 'version',
loaded: function (self) {
- return session.rpc('/web/webclient/version_info',{}).then(function (version) {
+ return self.session.rpc('/web/webclient/version_info',{}).then(function (version) {
self.version = version;
});
},
@@ -174,7 +181,7 @@ exports.PosModel = Backbone.Model.extend({
},{
model: 'res.company',
fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'state_id', 'tax_calculation_rounding_method'],
- ids: function(self){ return [session.user_context.allowed_company_ids[0]]; },
+ ids: function(self){ return [self.session.user_context.allowed_company_ids[0]]; },
loaded: function(self,companies){ self.company = companies[0]; },
},{
model: 'decimal.precision',
@@ -247,7 +254,7 @@ exports.PosModel = Backbone.Model.extend({
});
return new Promise(function (resolve, reject) {
var tax_ids = _.pluck(self.taxes, 'id');
- rpc.query({
+ self.rpc({
model: 'account.tax',
method: 'get_real_tax_amount',
args: [tax_ids],
@@ -311,7 +318,7 @@ exports.PosModel = Backbone.Model.extend({
return true;
}
});
- if (user.id === session.uid) {
+ if (user.id === self.session.uid) {
self.user = user;
self.employee.name = user.name;
self.employee.role = user.role;
@@ -382,7 +389,7 @@ exports.PosModel = Backbone.Model.extend({
},
},{
model: 'pos.category',
- fields: ['id', 'name', 'parent_id', 'child_id'],
+ fields: ['id', 'name', 'parent_id', 'child_id', 'write_date'],
domain: function(self) {
return self.config.limit_categories && self.config.iface_available_categ_ids.length ? [['id', 'in', self.config.iface_available_categ_ids]] : [];
},
@@ -393,7 +400,7 @@ exports.PosModel = Backbone.Model.extend({
model: 'product.product',
fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id',
'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description',
- 'product_tmpl_id','tracking'],
+ 'product_tmpl_id','tracking', 'write_date'],
order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }),
domain: function(self){
var domain = ['&', '&', ['sale_ok','=',true],['available_in_pos','=',true],'|',['company_id','=',self.config.company_id[0]],['company_id','=',false]];
@@ -416,6 +423,7 @@ exports.PosModel = Backbone.Model.extend({
product.lst_price = round_pr(product.lst_price * conversion_rate, self.currency.rounding);
}
product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]});
+ product.pos = self;
return new exports.Product({}, product);
}));
},
@@ -533,7 +541,7 @@ exports.PosModel = Backbone.Model.extend({
reject();
};
self.company_logo.crossOrigin = "anonymous";
- self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + session.db + '&_' + Math.random();
+ self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + self.session.db + '&_' + Math.random();
});
},
}, {
@@ -559,7 +567,7 @@ exports.PosModel = Backbone.Model.extend({
resolve();
} else {
var model = self.models[index];
- self.chrome.loading_message(_t('Loading')+' '+(model.label || model.model || ''), progress);
+ self.setLoadingMessage(_t('Loading')+' '+(model.label || model.model || ''), progress);
var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true;
if (!cond) {
@@ -577,7 +585,7 @@ exports.PosModel = Backbone.Model.extend({
if( model.model ){
var params = {
model: model.model,
- context: _.extend(context, session.user_context || {}),
+ context: _.extend(context, self.session.user_context || {}),
};
if (model.ids) {
@@ -590,7 +598,7 @@ exports.PosModel = Backbone.Model.extend({
params.orderBy = order;
}
- rpc.query(params).then(function (result) {
+ self.rpc(params).then(function (result) {
try { // catching exceptions in model.loaded(...)
Promise.resolve(model.loaded(self, result, tmp))
.then(function () { load_model(index + 1); },
@@ -637,7 +645,7 @@ exports.PosModel = Backbone.Model.extend({
return new Promise(function (resolve, reject) {
var fields = _.find(self.models, function(model){ return model.label === 'load_partners'; }).fields;
var domain = self.prepare_new_partners_domain();
- rpc.query({
+ self.rpc({
model: 'res.partner',
method: 'search_read',
args: [domain, fields],
@@ -649,7 +657,7 @@ exports.PosModel = Backbone.Model.extend({
if (self.db.add_partners(partners)) { // check if the partners we got were real updates
resolve();
} else {
- reject();
+ reject('Failed in updating partners.');
}
}, function (type, err) { reject(); });
});
@@ -819,7 +827,7 @@ exports.PosModel = Backbone.Model.extend({
if (order) {
order.get_orderlines().forEach(function (orderline) {
var product = orderline.product;
- var image_url = window.location.origin + '/web/image?model=product.product&field=image_128&id=' + product.id;
+ var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
// only download and convert image if we haven't done it before
if (! product.image_base64) {
@@ -832,18 +840,18 @@ exports.PosModel = Backbone.Model.extend({
return Promise.all(get_image_promises).then(function () {
var rendered_order_lines = "";
var rendered_payment_lines = "";
- var order_total_with_tax = self.chrome.format_currency(0);
+ var order_total_with_tax = self.format_currency(0);
if (order) {
rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', {
'orderlines': order.get_orderlines(),
- 'widget': self.chrome,
+ 'pos': self,
});
rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', {
'order': order,
- 'widget': self.chrome,
+ 'pos': self,
});
- order_total_with_tax = self.chrome.format_currency(order.get_total_with_tax());
+ order_total_with_tax = self.format_currency(order.get_total_with_tax());
}
var $rendered_html = $(rendered_html);
@@ -870,7 +878,7 @@ exports.PosModel = Backbone.Model.extend({
// saves the order locally and try to send it to the backend.
// it returns a promise that succeeds after having tried to send the order and all the other pending orders.
- push_order: function (order, opts) {
+ push_orders: function (order, opts) {
opts = opts || {};
var self = this;
@@ -889,6 +897,22 @@ exports.PosModel = Backbone.Model.extend({
});
},
+ push_single_order: function (order, opts) {
+ opts = opts || {};
+ const self = this;
+ const order_id = self.db.add_order(order.export_as_JSON());
+
+ return new Promise(function (resolve, reject) {
+ self.flush_mutex.exec(function () {
+ var flushed = self._flush_orders([self.db.get_order(order_id)], opts);
+
+ flushed.then(resolve, reject);
+
+ return flushed;
+ });
+ });
+ },
+
// saves the order locally and try to send it to the backend and make an invoice
// returns a promise that succeeds when the order has been posted and successfully generated
// an invoice. This method can fail in various ways:
@@ -926,7 +950,7 @@ exports.PosModel = Backbone.Model.extend({
transfer.then(function(order_server_id){
// generate the pdf and download it
if (order_server_id.length && !order.is_to_email()) {
- self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{
+ self.do_action('point_of_sale.pos_invoice_report',{additional_context:{
active_ids:order_server_id,
}}).then(function () {
resolveInvoiced(order_server_id);
@@ -956,6 +980,7 @@ exports.PosModel = Backbone.Model.extend({
},
// wrapper around the _save_to_server that updates the synch status widget
+ // Resolves to the backend ids of the synced orders.
_flush_orders: function(orders, options) {
var self = this;
this.set_synch('connecting', orders.length);
@@ -969,12 +994,12 @@ exports.PosModel = Backbone.Model.extend({
});
},
- set_synch: function(state, pending) {
- if (['connected', 'connecting', 'error', 'disconnected'].indexOf(state) === -1) {
- console.error(state, ' is not a known connection state.');
+ set_synch: function(status, pending) {
+ if (['connected', 'connecting', 'error', 'disconnected'].indexOf(status) === -1) {
+ console.error(status, ' is not a known connection state.');
}
pending = pending || this.db.get_orders().length + this.db.get_ids_to_remove_from_server().length;
- this.set('synch', { state: state, pending: pending });
+ this.set('synch', { status, pending });
},
// send an array of orders to the server
@@ -1004,11 +1029,11 @@ exports.PosModel = Backbone.Model.extend({
return order;
})];
args.push(options.draft || false);
- return rpc.query({
+ return this.rpc({
model: 'pos.order',
method: 'create_from_ui',
args: args,
- kwargs: {context: session.user_context},
+ kwargs: {context: this.session.user_context},
}, {
timeout: timeout,
shadow: !options.to_invoice
@@ -1021,18 +1046,14 @@ exports.PosModel = Backbone.Model.extend({
return server_ids;
}).catch(function (reason){
var error = reason.message;
+ console.warn('Failed to send orders:', orders);
if(error.code === 200 ){ // Business Logic Error, not a connection problem
// Hide error if already shown before ...
if ((!self.get('failed') || options.show_error) && !options.to_invoice) {
- self.gui.show_popup('error-traceback',{
- 'title': error.data.message,
- 'body': error.data.debug
- });
+ self.set('failed',error);
+ throw error;
}
- self.set('failed',error);
}
- console.warn('Failed to send orders:', orders);
- self.gui.show_sync_error_popup();
throw error;
});
},
@@ -1053,11 +1074,11 @@ exports.PosModel = Backbone.Model.extend({
var self = this;
var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length;
- return rpc.query({
+ return this.rpc({
model: 'pos.order',
method: 'remove_from_ui',
args: [server_ids],
- kwargs: {context: session.user_context},
+ kwargs: {context: this.session.user_context},
}, {
timeout: timeout,
shadow: true,
@@ -1066,8 +1087,17 @@ exports.PosModel = Backbone.Model.extend({
self.db.set_ids_removed_from_server(server_ids);
return server_ids;
}).catch(function (reason){
- self.gui.show_sync_error_popup();
- console.error('Failed to remove orders:', server_ids);
+ var error = reason.message;
+ if(error.code === 200 ){ // Business Logic Error, not a connection problem
+ //if warning do not need to display traceback!!
+ if (error.data.exception_type == 'warning') {
+ delete error.data.debug;
+ }
+ }
+ // important to throw error here and let the rendering component handle the
+ // error
+ console.warn('Failed to remove orders:', server_ids);
+ throw error;
});
},
@@ -1137,7 +1167,7 @@ exports.PosModel = Backbone.Model.extend({
this.db.add_order(json.paid_orders[i].data);
}
report.paid = json.paid_orders.length;
- this.push_order();
+ this.push_orders();
}
if (json.unpaid_orders) {
@@ -1212,6 +1242,62 @@ exports.PosModel = Backbone.Model.extend({
},
electronic_payment_interfaces: {},
+
+ format_currency: function(amount, precision) {
+ var currency =
+ this && this.currency
+ ? this.currency
+ : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 };
+
+ amount = this.format_currency_no_symbol(amount, precision, currency);
+
+ if (currency.position === 'after') {
+ return amount + ' ' + (currency.symbol || '');
+ } else {
+ return (currency.symbol || '') + ' ' + amount;
+ }
+ },
+
+ format_currency_no_symbol: function(amount, precision, currency) {
+ if (!currency) {
+ currency =
+ this && this.currency
+ ? this.currency
+ : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 };
+ }
+ var decimals = currency.decimals;
+
+ if (precision && this.dp[precision] !== undefined) {
+ decimals = this.dp[precision];
+ }
+
+ if (typeof amount === 'number') {
+ amount = round_di(amount, decimals).toFixed(decimals);
+ amount = field_utils.format.float(round_di(amount, decimals), {
+ digits: [69, decimals],
+ });
+ }
+
+ return amount;
+ },
+
+ format_pr: function(value, precision) {
+ var decimals =
+ precision > 0
+ ? Math.max(0, Math.ceil(Math.log(1.0 / precision) / Math.log(10)))
+ : 0;
+ return value.toFixed(decimals);
+ },
+
+ /**
+ * (value = 1.0000, decimals = 2) => '1'
+ * (value = 1.1234, decimals = 2) => '1.12'
+ * @param {number} value amount to format
+ */
+ formatFixed: function(value) {
+ const currency = this.currency || { decimals: 2 };
+ return `${Number(value.toFixed(currency.decimals || 0))}`;
+ },
});
/**
@@ -1324,7 +1410,21 @@ exports.Product = Backbone.Model.extend({
initialize: function(attr, options){
_.extend(this, options);
},
-
+ isAllowOnlyOneLot: function() {
+ const productUnit = this.get_unit();
+ return this.tracking === 'lot' || !productUnit || !productUnit.is_pos_groupable;
+ },
+ get_unit: function() {
+ var unit_id = this.uom_id;
+ if(!unit_id){
+ return undefined;
+ }
+ unit_id = unit_id[0];
+ if(!this.pos){
+ return undefined;
+ }
+ return this.pos.units_by_id[unit_id];
+ },
// Port of get_product_price on product.pricelist.
//
// Anything related to UOM can be ignored, the POS will always use
@@ -1474,6 +1574,57 @@ exports.Orderline = Backbone.Model.extend({
orderline.price_manually_set = this.price_manually_set;
return orderline;
},
+ getPackLotLinesToEdit: function(isAllowOnlyOneLot) {
+ const currentPackLotLines = this.pack_lot_lines.models;
+ let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length;
+ nExtraLines = nExtraLines > 0 ? nExtraLines : 1;
+ const tempLines = currentPackLotLines
+ .map(lotLine => ({
+ id: lotLine.cid,
+ text: lotLine.get('lot_name'),
+ }))
+ .concat(
+ Array.from(Array(nExtraLines)).map(_ => ({
+ text: '',
+ }))
+ );
+ return isAllowOnlyOneLot ? [tempLines[0]] : tempLines;
+ },
+ /**
+ * @param { modifiedPackLotLines, newPackLotLines }
+ * @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name)
+ * @param {Array} newPackLotLines array of { lot_name: String }
+ */
+ setPackLotLines: function({ modifiedPackLotLines, newPackLotLines }) {
+ // Set the new values for modified lot lines.
+ let lotLinesToRemove = [];
+ for (let lotLine of this.pack_lot_lines.models) {
+ const modifiedLotName = modifiedPackLotLines[lotLine.cid];
+ if (modifiedLotName) {
+ lotLine.set({ lot_name: modifiedLotName });
+ } else {
+ // We should not call lotLine.remove() here because
+ // we don't want to mutate the array while looping thru it.
+ lotLinesToRemove.push(lotLine);
+ }
+ }
+
+ // Remove those that needed to be removed.
+ for (let lotLine of lotLinesToRemove) {
+ lotLine.remove();
+ }
+
+ // Create new pack lot lines.
+ let newPackLotLine;
+ for (let newLotLine of newPackLotLines) {
+ newPackLotLine = new exports.Packlotline({}, { order_line: this });
+ newPackLotLine.set({ lot_name: newLotLine.lot_name });
+ this.pack_lot_lines.add(newPackLotLine);
+ }
+
+ // Set the quantity of the line based on number of pack lots.
+ this.pack_lot_lines.set_quantity_by_lot();
+ },
set_product_lot: function(product){
this.has_product_lot = product.tracking !== 'none';
this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null, {'order_line': this});
@@ -1552,24 +1703,6 @@ exports.Orderline = Backbone.Model.extend({
return lots_required;
},
- compute_lot_lines: function(){
- var pack_lot_lines = this.pack_lot_lines;
- var lines = pack_lot_lines.length;
- var lots_required = this.get_required_number_of_lots();
-
- if(lots_required > lines){
- for(var i=0; i<lots_required - lines; i++){
- pack_lot_lines.add(new exports.Packlotline({}, {'order_line': this}));
- }
- }
- if(lots_required < lines){
- var to_remove = lines - lots_required;
- var lot_lines = pack_lot_lines.sortBy('lot_name').slice(0, to_remove);
- pack_lot_lines.remove(lot_lines);
- }
- return this.pack_lot_lines;
- },
-
has_valid_product_lot: function(){
if(!this.has_product_lot){
return true;
@@ -1580,15 +1713,7 @@ exports.Orderline = Backbone.Model.extend({
// return the unit of measure of the product
get_unit: function(){
- var unit_id = this.product.uom_id;
- if(!unit_id){
- return undefined;
- }
- unit_id = unit_id[0];
- if(!this.pos){
- return undefined;
- }
- return this.pos.units_by_id[unit_id];
+ return this.product.get_unit();
},
// return the product of this orderline
get_product: function(){
@@ -1597,7 +1722,9 @@ exports.Orderline = Backbone.Model.extend({
// selects or deselects this orderline
set_selected: function(selected){
this.selected = selected;
+ // this trigger also triggers the change event of the collection.
this.trigger('change',this);
+ this.trigger('new-orderline-selected');
},
// returns true if this orderline is selected
is_selected: function(){
@@ -1648,6 +1775,7 @@ exports.Orderline = Backbone.Model.extend({
//used to create a json of the ticket, to be sent to the printer
export_for_printing: function(){
return {
+ id: this.id,
quantity: this.get_quantity(),
unit_name: this.get_unit().name,
price: this.get_unit_display_price(),
@@ -2115,14 +2243,6 @@ var PacklotlineCollection = Backbone.Collection.extend({
this.order_line = options.order_line;
},
- get_empty_model: function(){
- return this.findWhere({'lot_name': null});
- },
-
- remove_empty_model: function(){
- this.remove(this.where({'lot_name': null}));
- },
-
get_valid_lots: function(){
return this.filter(function(model){
return model.get('lot_name');
@@ -2130,13 +2250,11 @@ var PacklotlineCollection = Backbone.Collection.extend({
},
set_quantity_by_lot: function() {
- if (this.order_line.product.tracking == 'serial') {
- var valid_lots_quantity = this.get_valid_lots().length;
- if (this.order_line.quantity < 0){
- valid_lots_quantity = -valid_lots_quantity;
- }
- this.order_line.set_quantity(valid_lots_quantity);
+ var valid_lots_quantity = this.get_valid_lots().length;
+ if (this.order_line.quantity < 0){
+ valid_lots_quantity = -valid_lots_quantity;
}
+ this.order_line.set_quantity(valid_lots_quantity);
}
});
@@ -2174,7 +2292,7 @@ exports.Paymentline = Backbone.Model.extend({
set_amount: function(value){
this.order.assert_editable();
this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals);
- this.pos.send_current_order_to_customer_facing_display();
+ if (this.pos.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display();
this.trigger('change',this);
},
// returns the amount of money on this paymentline
@@ -2242,6 +2360,7 @@ exports.Paymentline = Backbone.Model.extend({
//exports as JSON for receipt printing
export_for_printing: function(){
return {
+ cid: this.cid,
amount: this.get_amount(),
payment_method: this.payment_method.name,
};
@@ -2455,7 +2574,7 @@ exports.Order = Backbone.Model.extend({
qweb.default_dict = _.clone(QWeb.default_dict);
qweb.add_template('<templates><t t-name="subreceipt">'+subreceipt+'</t></templates>');
- return qweb.render('subreceipt',{'pos':self.pos,'widget':self.pos.chrome,'order':self, 'receipt': receipt}) ;
+ return qweb.render('subreceipt',{'pos':self.pos,'order':self, 'receipt': receipt}) ;
}
}
@@ -2644,9 +2763,6 @@ exports.Order = Backbone.Model.extend({
}
this.assert_editable();
options = options || {};
- var attr = JSON.parse(JSON.stringify(product));
- attr.pos = this.pos;
- attr.order = this;
var line = new exports.Orderline({}, {pos: this.pos, order: this, product: product});
this.fix_tax_included_price(line);
@@ -2687,8 +2803,8 @@ exports.Order = Backbone.Model.extend({
this.select_orderline(this.get_last_orderline());
}
- if(line.has_product_lot){
- this.display_lot_popup();
+ if (options.draftPackLotLines) {
+ this.selected_orderline.setPackLotLines(options.draftPackLotLines);
}
if (this.pos.config.iface_customer_facing_display) {
this.pos.send_current_order_to_customer_facing_display();
@@ -2700,6 +2816,10 @@ exports.Order = Backbone.Model.extend({
select_orderline: function(line){
if(line){
if(line !== this.selected_orderline){
+ // if line (new line to select) is not the same as the old
+ // selected_orderline, then we set the old line to false,
+ // and set the new line to true. Also, set the new line as
+ // the selected_orderline.
if(this.selected_orderline){
this.selected_orderline.set_selected(false);
}
@@ -2717,19 +2837,6 @@ exports.Order = Backbone.Model.extend({
}
},
- display_lot_popup: function() {
- var order_line = this.get_selected_orderline();
- if (order_line){
- var pack_lot_lines = order_line.compute_lot_lines();
- this.pos.gui.show_popup('packlotline', {
- 'title': _t('Lot/Serial Number(s) Required'),
- 'pack_lot_lines': pack_lot_lines,
- 'order_line': order_line,
- 'order': this,
- });
- }
- },
-
/* ---- Payment Lines --- */
add_paymentline: function(payment_method) {
this.assert_editable();
@@ -2739,7 +2846,7 @@ exports.Order = Backbone.Model.extend({
};
this.paymentlines.add(newPaymentline);
this.select_paymentline(newPaymentline);
-
+ return newPaymentline;
},
get_paymentlines: function(){
return this.paymentlines.models;
@@ -3064,105 +3171,49 @@ exports.Order = Backbone.Model.extend({
wait_for_push_order: function () {
return this.is_to_email();
},
+
+ /**
+ * @returns {Object} object to use as props for instantiating OrderReceipt.
+ */
+ getOrderReceiptEnv: function() {
+ // Formerly get_receipt_render_env defined in ScreenWidget.
+ return {
+ order: this,
+ receipt: this.export_for_printing(),
+ orderlines: this.get_orderlines(),
+ paymentlines: this.get_paymentlines(),
+ };
+ },
+ updatePricelist: function(newClient) {
+ let newClientPricelist, newClientFiscalPosition;
+ const defaultFiscalPosition = this.pos.fiscal_positions.find(
+ (position) => position.id === this.pos.config.default_fiscal_position_id[0]
+ );
+ if (newClient) {
+ newClientFiscalPosition = newClient.property_account_position_id
+ ? this.pos.fiscal_positions.find(
+ (position) => position.id === newClient.property_account_position_id[0]
+ )
+ : defaultFiscalPosition;
+ newClientPricelist =
+ this.pos.pricelists.find(
+ (pricelist) => pricelist.id === newClient.property_product_pricelist[0]
+ ) || this.pos.default_pricelist;
+ } else {
+ newClientFiscalPosition = defaultFiscalPosition;
+ newClientPricelist = this.pos.default_pricelist;
+ }
+ this.fiscal_position = newClientFiscalPosition;
+ this.set_pricelist(newClientPricelist);
+ }
});
var OrderCollection = Backbone.Collection.extend({
model: exports.Order,
});
-/*
- The numpad handles both the choice of the property currently being modified
- (quantity, price or discount) and the edition of the corresponding numeric value.
- */
-exports.NumpadState = Backbone.Model.extend({
- defaults: {
- buffer: "0",
- mode: "quantity"
- },
- appendNewChar: function(newChar) {
- var oldBuffer;
- oldBuffer = this.get('buffer');
- if (oldBuffer === '0') {
- this.set({
- buffer: newChar
- });
- } else if (oldBuffer === '-0') {
- this.set({
- buffer: "-" + newChar
- });
- } else if (!(newChar === '.') || oldBuffer.indexOf('.') === -1) {
- this.set({
- buffer: (this.get('buffer')) + newChar
- });
- }
- this.trigger('set_value',this.get('buffer'));
- },
- deleteLastChar: function() {
- if(this.get('buffer') === ""){
- if(this.get('mode') === 'quantity'){
- this.trigger('set_value','remove');
- }else{
- this.trigger('set_value',this.get('buffer'));
- }
- } else if (this.get('buffer') === "-0") {
- this.set({ buffer: "0" });
- this.trigger('set_value',this.get('buffer'));
- }else{
- var newBuffer = this.get('buffer').slice(0,-1) || "";
- this.set({ buffer: newBuffer });
- this.trigger('set_value',this.get('buffer'));
- }
- },
- switchSign: function() {
- var oldBuffer;
- oldBuffer = this.get('buffer');
- this.set({
- buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
- });
- this.trigger('set_value',this.get('buffer'));
- },
- positiveSign: function() {
- var oldBuffer;
- oldBuffer = this.get('buffer');
- if (oldBuffer[0] === '-'){
- this.set({buffer: oldBuffer.substr(1)});
- this.trigger('set_value',this.get('buffer'));
- }
- },
- negativeSign: function() {
- var oldBuffer;
- oldBuffer = this.get('buffer');
- if (oldBuffer[0] !== '-'){
- this.set({buffer: "-" + oldBuffer});
- this.trigger('set_value',this.get('buffer'));
- }
- },
- changeMode: function(newMode) {
- this.set({
- buffer: "0",
- mode: newMode
- });
- },
- reset: function() {
- this.set({
- buffer: "0",
- mode: "quantity"
- });
- },
- resetValue: function(){
- if(this.get('buffer') === "") {
- this.trigger('set_value','remove');
- }
- else {
- this.set({ buffer: "" });
- this.trigger('set_value',this.get('buffer'));
- }
- },
-});
-
// exports = {
// PosModel: PosModel,
-// NumpadState: NumpadState,
// load_fields: load_fields,
// load_models: load_models,
// Orderline: Orderline,
diff --git a/addons/point_of_sale/static/src/js/popups.js b/addons/point_of_sale/static/src/js/popups.js
deleted file mode 100644
index 89d9b8d6a7e..00000000000
--- a/addons/point_of_sale/static/src/js/popups.js
+++ /dev/null
@@ -1,343 +0,0 @@
-odoo.define('point_of_sale.popups', function (require) {
-"use strict";
-
-// This file contains the Popups.
-// Popups must be loaded and named in chrome.js.
-// They are instanciated / destroyed with the .gui.show_popup()
-// and .gui.close_popup() methods.
-
-var PosBaseWidget = require('point_of_sale.BaseWidget');
-var gui = require('point_of_sale.gui');
-var _t = require('web.core')._t;
-
-
-var PopupWidget = PosBaseWidget.extend({
- template: 'PopupWidget',
- init: function(parent, args) {
- this._super(parent, args);
- this.options = {};
- },
- events: {
- 'click .button.cancel': 'click_cancel',
- 'click .button.confirm': 'click_confirm',
- 'click .selection-item': 'click_item',
- 'click .input-button': 'click_numpad',
- 'click .mode-button': 'click_numpad',
- },
-
- // show the popup !
- show: function(options){
- if(this.$el){
- this.$el.removeClass('oe_hidden');
- }
-
- if (typeof options === 'string') {
- this.options = {title: options};
- } else {
- this.options = options || {};
- }
-
- this.renderElement();
-
- // popups block the barcode reader ...
- if (this.pos.barcode_reader) {
- this.pos.barcode_reader.save_callbacks();
- this.pos.barcode_reader.reset_action_callbacks();
- }
- },
-
- // called before hide, when a popup is closed.
- // extend this if you want a custom action when the
- // popup is closed.
- close: function(){
- if (this.pos.barcode_reader) {
- this.pos.barcode_reader.restore_callbacks();
- }
- },
-
- // hides the popup. keep in mind that this is called in
- // the initialization pass of the pos instantiation,
- // so you don't want to do anything fancy in here
- hide: function(){
- if (this.$el) {
- this.$el.addClass('oe_hidden');
- }
- },
-
- // what happens when we click cancel
- // ( it should close the popup and do nothing )
- click_cancel: function(){
- this.gui.close_popup();
- if (this.options.cancel) {
- this.options.cancel.call(this);
- }
- },
-
- // what happens when we confirm the action
- click_confirm: function(){
- this.gui.close_popup();
- if (this.options.confirm) {
- this.options.confirm.call(this);
- }
- },
-
- // Since Widget does not support extending the events declaration
- // we declared them all in the top class.
- click_item: function(){},
- click_numad: function(){},
-});
-gui.define_popup({name:'alert', widget: PopupWidget});
-
-var ErrorPopupWidget = PopupWidget.extend({
- template:'ErrorPopupWidget',
- show: function(options){
- this._super(options);
- this.gui.play_sound('error');
- },
-});
-gui.define_popup({name:'error', widget: ErrorPopupWidget});
-
-
-var SyncErrorPopupWidget = ErrorPopupWidget.extend({
- template:'SyncErrorPopupWidget',
- show: function(opts) {
- var self = this;
- this._super(opts);
-
- this.$('.stop_showing_sync_errors').off('click').click(function(){
- self.gui.show_sync_errors = false;
- self.click_confirm();
- });
- }
-});
-gui.define_popup({name:'error-sync', widget: SyncErrorPopupWidget});
-
-
-var ErrorTracebackPopupWidget = ErrorPopupWidget.extend({
- template:'ErrorTracebackPopupWidget',
- show: function(opts) {
- var self = this;
- this._super(opts);
-
- this.$('.download').off('click').click(function(){
- self.gui.prepare_download_link(self.options.body,
- _t('error') + ' ' + moment().format('YYYY-MM-DD-HH-mm-ss') + '.txt',
- '.download', '.download_error_file');
- });
-
- this.$('.email').off('click').click(function(){
- self.gui.send_email( self.pos.company.email,
- _t('IMPORTANT: Bug Report From Odoo Point Of Sale'),
- self.options.body);
- });
- }
-});
-gui.define_popup({name:'error-traceback', widget: ErrorTracebackPopupWidget});
-
-
-var ErrorBarcodePopupWidget = ErrorPopupWidget.extend({
- template:'ErrorBarcodePopupWidget',
- show: function(barcode){
- this._super({barcode: barcode});
- },
-});
-gui.define_popup({name:'error-barcode', widget: ErrorBarcodePopupWidget});
-
-
-var ConfirmPopupWidget = PopupWidget.extend({
- template: 'ConfirmPopupWidget',
-});
-gui.define_popup({name:'confirm', widget: ConfirmPopupWidget});
-
-/**
- * A popup that allows the user to select one item from a list.
- *
- * Example::
- *
- * show_popup('selection',{
- * title: "Popup Title",
- * list: [
- * { label: 'foobar', item: 45 },
- * { label: 'bar foo', item: 'stuff' },
- * ],
- * confirm: function(item) {
- * // get the item selected by the user.
- * },
- * cancel: function(){
- * // user chose nothing
- * }
- * });
- */
-
-var SelectionPopupWidget = PopupWidget.extend({
- template: 'SelectionPopupWidget',
- show: function(options){
- var self = this;
- options = options || {};
- this._super(options);
-
- this.list = options.list || [];
- this.is_selected = options.is_selected || function (item) { return false; };
- this.renderElement();
- },
- click_item : function(event) {
- this.gui.close_popup();
- if (this.options.confirm) {
- var item = this.list[parseInt($(event.target).data('item-index'))];
- item = item ? item.item : item;
- this.options.confirm.call(self,item);
- }
- }
-});
-gui.define_popup({name:'selection', widget: SelectionPopupWidget});
-
-
-var TextInputPopupWidget = PopupWidget.extend({
- template: 'TextInputPopupWidget',
- show: function(options){
- options = options || {};
- this._super(options);
-
- this.renderElement();
- this.$('input,textarea').focus();
- },
- click_confirm: function(){
- var value = this.$('input,textarea').val();
- this.gui.close_popup();
- if( this.options.confirm ){
- this.options.confirm.call(this,value);
- }
- },
-});
-gui.define_popup({name:'textinput', widget: TextInputPopupWidget});
-
-
-var TextAreaPopupWidget = TextInputPopupWidget.extend({
- template: 'TextAreaPopupWidget',
-});
-gui.define_popup({name:'textarea', widget: TextAreaPopupWidget});
-
-var PackLotLinePopupWidget = PopupWidget.extend({
- template: 'PackLotLinePopupWidget',
- events: _.extend({}, PopupWidget.prototype.events, {
- 'click .remove-lot': 'remove_lot',
- 'keydown': 'add_lot',
- 'blur .packlot-line-input': 'lose_input_focus'
- }),
-
- show: function(options){
- this._super(options);
- this.focus();
- },
-
- click_confirm: function(){
- var pack_lot_lines = this.options.pack_lot_lines;
- this.$('.packlot-line-input').each(function(index, el){
- var cid = $(el).attr('cid'),
- lot_name = $(el).val();
- var pack_line = pack_lot_lines.get({cid: cid});
- pack_line.set_lot_name(lot_name);
- });
- pack_lot_lines.remove_empty_model();
- pack_lot_lines.set_quantity_by_lot();
- this.options.order.save_to_db();
- this.options.order_line.trigger('change', this.options.order_line);
- this.gui.close_popup();
- },
-
- add_lot: function(ev) {
- if (ev.keyCode === $.ui.keyCode.ENTER && this.options.order_line.product.tracking == 'serial'){
- var pack_lot_lines = this.options.pack_lot_lines,
- $input = $(ev.target),
- cid = $input.attr('cid'),
- lot_name = $input.val();
-
- var lot_model = pack_lot_lines.get({cid: cid});
- lot_model.set_lot_name(lot_name); // First set current model then add new one
- if(!pack_lot_lines.get_empty_model()){
- var new_lot_model = lot_model.add();
- this.focus_model = new_lot_model;
- }
- pack_lot_lines.set_quantity_by_lot();
- this.renderElement();
- this.focus();
- }
- },
-
- remove_lot: function(ev){
- var pack_lot_lines = this.options.pack_lot_lines,
- $input = $(ev.target).prev(),
- cid = $input.attr('cid');
- var lot_model = pack_lot_lines.get({cid: cid});
- lot_model.remove();
- pack_lot_lines.set_quantity_by_lot();
- this.renderElement();
- },
-
- lose_input_focus: function(ev){
- var $input = $(ev.target),
- cid = $input.attr('cid');
- var lot_model = this.options.pack_lot_lines.get({cid: cid});
- lot_model.set_lot_name($input.val());
- },
-
- focus: function(){
- this.$("input[autofocus]").focus();
- this.focus_model = false; // after focus clear focus_model on widget
- }
-});
-gui.define_popup({name:'packlotline', widget:PackLotLinePopupWidget});
-
-var NumberPopupWidget = PopupWidget.extend({
- template: 'NumberPopupWidget',
- show: function(options){
- options = options || {};
- this._super(options);
-
- this.inputbuffer = '' + (options.value || '');
- this.decimal_separator = _t.database.parameters.decimal_point;
- this.renderElement();
- this.firstinput = true;
- },
- click_numpad: function(event){
- var newbuf = this.gui.numpad_input(
- this.inputbuffer,
- $(event.target).data('action'),
- {'firstinput': this.firstinput});
-
- this.firstinput = (newbuf.length === 0);
-
- if (newbuf !== this.inputbuffer) {
- this.inputbuffer = newbuf;
- this.$('.value').text(this.inputbuffer);
- }
- },
- click_confirm: function(){
- this.gui.close_popup();
- if( this.options.confirm ){
- this.options.confirm.call(this,this.inputbuffer);
- }
- },
-});
-gui.define_popup({name:'number', widget: NumberPopupWidget});
-
-var PasswordPopupWidget = NumberPopupWidget.extend({
- renderElement: function(){
- this._super();
- this.$('.popup').addClass('popup-password');
- },
- click_numpad: function(event){
- this._super.apply(this, arguments);
- var $value = this.$('.value');
- $value.text($value.text().replace(/./g, '•'));
- },
-});
-gui.define_popup({name:'password', widget: PasswordPopupWidget});
-
-var OrderImportPopupWidget = PopupWidget.extend({
- template: 'OrderImportPopupWidget',
-});
-gui.define_popup({name:'orderimport', widget: OrderImportPopupWidget});
-
-return PopupWidget;
-});
diff --git a/addons/point_of_sale/static/src/js/printers.js b/addons/point_of_sale/static/src/js/printers.js
index 3e4a21d96a5..b0332f604c9 100644
--- a/addons/point_of_sale/static/src/js/printers.js
+++ b/addons/point_of_sale/static/src/js/printers.js
@@ -5,31 +5,75 @@ var Session = require('web.Session');
var core = require('web.core');
var _t = core._t;
+// IMPROVEMENT: This is too much. We can get away from this class.
+class PrintResult {
+ constructor({ successful, message }) {
+ this.successful = successful;
+ this.message = message;
+ }
+}
+
+class PrintResultGenerator {
+ IoTActionError() {
+ return new PrintResult({
+ successful: false,
+ message: {
+ title: _t('Connection to IoT Box failed'),
+ body: _t('Please check if the IoT Box is still connected.'),
+ },
+ });
+ }
+ IoTResultError() {
+ return new PrintResult({
+ successful: false,
+ message: {
+ title: _t('Connection to the printer failed'),
+ body: _t('Please check if the printer is still connected.'),
+ },
+ });
+ }
+ Successful() {
+ return new PrintResult({
+ successful: true,
+ });
+ }
+}
+
var PrinterMixin = {
- init: function () {
+ init: function() {
this.receipt_queue = [];
+ this.printResultGenerator = new PrintResultGenerator();
},
/**
* Add the receipt to the queue of receipts to be printed and process it.
+ * We clear the print queue if printing is not successful.
* @param {String} receipt: The receipt to be printed, in HTML
+ * @returns {PrintResult}
*/
- print_receipt: function (receipt) {
- var self = this;
+ print_receipt: async function(receipt) {
if (receipt) {
this.receipt_queue.push(receipt);
}
- function process_next_job() {
- if (self.receipt_queue.length > 0) {
- var r = self.receipt_queue.shift();
- return self.htmlToImg(r)
- .then(self.send_printing_job.bind(self))
- .then(self._onIoTActionResult.bind(self))
- .then(process_next_job)
- .guardedCatch(self._onIoTActionFail.bind(self));
+ let image, sendPrintResult;
+ while (this.receipt_queue.length > 0) {
+ receipt = this.receipt_queue.shift();
+ image = await this.htmlToImg(receipt);
+ try {
+ sendPrintResult = await this.send_printing_job(image);
+ } catch (error) {
+ // Error in communicating to the IoT box.
+ this.receipt_queue.length = 0;
+ return this.printResultGenerator.IoTActionError();
+ }
+ // rpc call is okay but printing failed because
+ // IoT box can't find a printer.
+ if (!sendPrintResult) {
+ this.receipt_queue.length = 0;
+ return this.printResultGenerator.IoTResultError();
}
}
- return process_next_job();
+ return this.printResultGenerator.Successful();
},
/**
@@ -64,7 +108,7 @@ var PrinterMixin = {
_onIoTActionResult: function (data){
if (this.pos && (data === false || data.result === false)) {
- this.pos.gui.show_popup('error',{
+ Gui.showPopup('ErrorPopup',{
'title': _t('Connection to the printer failed'),
'body': _t('Please check if the printer is still connected.'),
});
@@ -73,7 +117,7 @@ var PrinterMixin = {
_onIoTActionFail: function () {
if (this.pos) {
- this.pos.gui.show_popup('error',{
+ Gui.showPopup('ErrorPopup',{
'title': _t('Connection to IoT Box failed'),
'body': _t('Please check if the IoT Box is still connected.'),
});
@@ -120,5 +164,7 @@ var Printer = core.Class.extend(PrinterMixin, {
return {
PrinterMixin: PrinterMixin,
Printer: Printer,
+ PrintResult,
+ PrintResultGenerator,
}
});
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
deleted file mode 100644
index e15c49971a5..00000000000
--- a/addons/point_of_sale/static/src/js/screens.js
+++ /dev/null
@@ -1,2625 +0,0 @@
-odoo.define('point_of_sale.screens', function (require) {
-"use strict";
-// This file contains the Screens definitions. Screens are the
-// content of the right pane of the pos, containing the main functionalities.
-//
-// Screens must be defined and named in chrome.js before use.
-//
-// Screens transitions are controlled by the Gui.
-// gui.set_startup_screen() sets the screen displayed at startup
-// gui.set_default_screen() sets the screen displayed for new orders
-// gui.show_screen() shows a screen
-// gui.back() goes to the previous screen
-//
-// Screen state is saved in the order. When a new order is selected,
-// a screen is displayed based on the state previously saved in the order.
-// this is also done in the Gui with:
-// gui.show_saved_screen()
-//
-// All screens inherit from ScreenWidget. The only addition from the base widgets
-// are show() and hide() which shows and hides the screen but are also used to
-// bind and unbind actions on widgets and devices. The gui guarantees
-// that only one screen is shown at the same time and that show() is called after all
-// hide()s
-//
-// Each Screens must be independant from each other, and should have no
-// persistent state outside the models. Screen state variables are reset at
-// each screen display. A screen can be called with parameters, which are
-// to be used for the duration of the screen only.
-
-var PosBaseWidget = require('point_of_sale.BaseWidget');
-var gui = require('point_of_sale.gui');
-var models = require('point_of_sale.models');
-var core = require('web.core');
-var rpc = require('web.rpc');
-var utils = require('web.utils');
-var field_utils = require('web.field_utils');
-var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents;
-var Printer = require('point_of_sale.Printer').Printer;
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var round_pr = utils.round_precision;
-
-/*--------------------------------------*\
- | THE SCREEN WIDGET |
-\*======================================*/
-
-// The screen widget is the base class inherited
-// by all screens.
-var ScreenWidget = PosBaseWidget.extend({
-
- init: function(parent,options){
- this._super(parent,options);
- this.hidden = false;
- },
-
- barcode_product_screen: 'products', //if defined, this screen will be loaded when a product is scanned
-
- // what happens when a product is scanned :
- // it will add the product to the order and go to barcode_product_screen.
- barcode_product_action: function(code){
- var self = this;
- if (self.pos.scan_product(code)) {
- if (self.barcode_product_screen) {
- self.gui.show_screen(self.barcode_product_screen, null, null, true);
- }
- } else {
- this.barcode_error_action(code);
- }
- },
-
- // what happens when a client id barcode is scanned.
- // the default behavior is the following :
- // - if there's a user with a matching barcode, put it as the active 'client' and return true
- // - else : return false.
- barcode_client_action: function(code){
- var partner = this.pos.db.get_partner_by_barcode(code.code);
- if(partner){
- if (this.pos.get_order().get_client() !== partner) {
- this.pos.get_order().set_client(partner);
- this.pos.get_order().set_pricelist(_.findWhere(this.pos.pricelists, {'id': partner.property_product_pricelist[0]}) || this.pos.default_pricelist);
- }
- return true;
- }
- this.barcode_error_action(code);
- return false;
- },
-
- // what happens when a discount barcode is scanned : the default behavior
- // is to set the discount on the last order.
- barcode_discount_action: function(code){
- var last_orderline = this.pos.get_order().get_last_orderline();
- if(last_orderline){
- last_orderline.set_discount(code.value);
- }
- },
- // What happens when an invalid barcode is scanned : shows an error popup.
- barcode_error_action: function(code) {
- var show_code;
- if (code.code.length > 32) {
- show_code = code.code.substring(0,29)+'...';
- } else {
- show_code = code.code;
- }
- this.gui.show_popup('error-barcode',show_code);
- },
-
- // this method shows the screen and sets up all the widget related to this screen. Extend this method
- // if you want to alter the behavior of the screen.
- show: function(){
- var self = this;
-
- this.hidden = false;
- if(this.$el){
- this.$el.removeClass('oe_hidden');
- }
-
- this.pos.barcode_reader.set_action_callback({
- 'product': _.bind(self.barcode_product_action, self),
- 'weight': _.bind(self.barcode_product_action, self),
- 'price': _.bind(self.barcode_product_action, self),
- 'client' : _.bind(self.barcode_client_action, self),
- 'discount': _.bind(self.barcode_discount_action, self),
- 'error' : _.bind(self.barcode_error_action, self),
- });
- },
-
- // this method is called when the screen is closed to make place for a new screen. this is a good place
- // to put your cleanup stuff as it is guaranteed that for each show() there is one and only one close()
- close: function(){
- if(this.pos.barcode_reader){
- this.pos.barcode_reader.reset_action_callbacks();
- }
-
- if(this.payment_interface){
- this.payment_interface.close();
- }
- },
-
- // this methods hides the screen. It's not a good place to put your cleanup stuff as it is called on the
- // POS initialization.
- hide: function(){
- this.hidden = true;
- if(this.$el){
- this.$el.addClass('oe_hidden');
- }
- },
-
- // we need this because some screens re-render themselves when they are hidden
- // (due to some events, or magic, or both...) we must make sure they remain hidden.
- // the good solution would probably be to make them not re-render themselves when they
- // are hidden.
- renderElement: function(){
- this._super();
- if(this.hidden){
- if(this.$el){
- this.$el.addClass('oe_hidden');
- }
- }
- },
- /**
- * Handles the error response from the server when we push
- * an invoiceable order
- * Displays appropriates warnings and errors and
- * proposes subsequent actions
- *
- * @private
- * @param {PosModel} order: the order to consider, defaults to current order
- * @param {Boolean} refresh_screens: whether or not displayed screens should refresh
- * @param {Object} error: the error provided by Ajax
- */
- _handleFailedPushForInvoice: function (order, refresh_screen, error) {
- var self = this;
- order = order || this.pos.get_order();
- this.invoicing = false;
- order.finalized = false;
- if (error.message === 'Missing Customer') {
- this.gui.show_popup('confirm',{
- 'title': _t('Please select the Customer'),
- 'body': _t('You need to select the customer before you can invoice an order.'),
- confirm: function(){
- self.gui.show_screen('clientlist', null, refresh_screen);
- },
- });
- } else if (error.message === 'Backend Invoice') {
- this.gui.show_popup('confirm',{
- 'title': _t('Please print the invoice from the backend'),
- 'body': _t('The order has been synchronized earlier. Please make the invoice from the backend for the order: ') + error.data.order.name,
- confirm: function () {
- this.gui.show_screen('receipt', null, refresh_screen);
- },
- cancel: function () {
- this.gui.show_screen('receipt', null, refresh_screen);
- },
- });
- } else if (error.code < 0) { // XmlHttpRequest Errors
- this.gui.show_popup('error',{
- 'title': _t('The order could not be sent'),
- 'body': _t('Check your internet connection and try again.'),
- cancel: function () {
- this.gui.show_screen('receipt', {button_print_invoice: true}, refresh_screen); // refresh if necessary
- },
- });
- } else if (error.code === 200) { // OpenERP Server Errors
- this.gui.show_popup('error-traceback',{
- 'title': error.data.message || _t("Server Error"),
- 'body': error.data.debug || _t('The server encountered an error while receiving your order.'),
- });
- } else { // ???
- this.gui.show_popup('error',{
- 'title': _t("Unknown Error"),
- 'body': _t("The order could not be sent to the server due to an unknown error"),
- });
- }
- },
-});
-
-/*--------------------------------------*\
- | THE DOM CACHE |
-\*======================================*/
-
-// The Dom Cache is used by various screens to improve
-// their performances when displaying many time the
-// same piece of DOM.
-//
-// It is a simple map from string 'keys' to DOM Nodes.
-//
-// The cache empties itself based on usage frequency
-// stats, so you may not always get back what
-// you put in.
-
-var DomCache = core.Class.extend({
- init: function(options){
- options = options || {};
- this.max_size = options.max_size || 2000;
-
- this.cache = {};
- this.access_time = {};
- this.size = 0;
- },
- cache_node: function(key,node){
- var cached = this.cache[key];
- this.cache[key] = node;
- this.access_time[key] = new Date().getTime();
- if(!cached){
- this.size++;
- while(this.size >= this.max_size){
- var oldest_key = null;
- var oldest_time = new Date().getTime();
- for(key in this.cache){
- var time = this.access_time[key];
- if(time <= oldest_time){
- oldest_time = time;
- oldest_key = key;
- }
- }
- if(oldest_key){
- delete this.cache[oldest_key];
- delete this.access_time[oldest_key];
- }
- this.size--;
- }
- }
- return node;
- },
- clear_node: function(key) {
- var cached = this.cache[key];
- if (cached) {
- delete this.cache[key];
- delete this.access_time[key];
- this.size --;
- }
- },
- get_node: function(key){
- var cached = this.cache[key];
- if(cached){
- this.access_time[key] = new Date().getTime();
- }
- return cached;
- },
-});
-
-/*--------------------------------------*\
- | THE SCALE SCREEN |
-\*======================================*/
-
-// The scale screen displays the weight of
-// a product on the electronic scale.
-
-var ScaleScreenWidget = ScreenWidget.extend({
- template:'ScaleScreenWidget',
-
- next_screen: 'products',
- previous_screen: 'products',
-
- show: function(){
- this._super();
- var self = this;
-
- this.set_weight(0);
- this.renderElement();
-
- this.hotkey_handler = function(event){
- if(event.which === 13){
- self.order_product();
- self.gui.show_screen(self.next_screen);
- }else if(event.which === 27){
- self.gui.show_screen(self.previous_screen);
- }
- };
-
- $('body').on('keypress',this.hotkey_handler);
-
- this.$('.back').click(function(){
- self.gui.show_screen(self.previous_screen);
- });
-
- this.$('.next,.buy-product').click(function(){
- self.gui.show_screen(self.next_screen);
- // add product *after* switching screen to scroll properly
- self.order_product();
- });
- this._read_scale();
- },
-
- _read_scale: function() {
- var self = this;
- var queue = this.pos.proxy_queue;
- queue.schedule(function(){
- return self.pos.proxy.scale_read().then(function(weight){
- self.set_weight(weight.weight);
- });
- },{duration:500, repeat: true});
-
- },
- get_product: function(){
- return this.gui.get_current_screen_param('product');
- },
- _get_active_pricelist: function(){
- var current_order = this.pos.get_order();
- var current_pricelist = this.pos.default_pricelist;
-
- if (current_order) {
- current_pricelist = current_order.pricelist;
- }
-
- return current_pricelist;
- },
- order_product: function(){
- this.pos.get_order().add_product(this.get_product(),{ quantity: this.weight });
- },
- get_product_name: function(){
- var product = this.get_product();
- return (product ? product.display_name : undefined) || 'Unnamed Product';
- },
- get_product_price: function(){
- var product = this.get_product();
- var pricelist = this._get_active_pricelist();
- return (product ? product.get_price(pricelist, this.weight) : 0) || 0;
- },
- get_product_uom: function(){
- var product = this.get_product();
-
- if(product){
- return this.pos.units_by_id[product.uom_id[0]].name;
- }else{
- return '';
- }
- },
- set_weight: function(weight){
- this.weight = weight;
- this.$('.weight').text(this.get_product_weight_string());
- this.$('.computed-price').text(this.get_computed_price_string());
- },
- get_product_weight_string: function(){
- var product = this.get_product();
- var defaultstr = (this.weight || 0).toFixed(3) + ' Kg';
- if(!product || !this.pos){
- return defaultstr;
- }
- var unit_id = product.uom_id;
- if(!unit_id){
- return defaultstr;
- }
- var unit = this.pos.units_by_id[unit_id[0]];
- var weight = round_pr(this.weight || 0, unit.rounding);
- var weightstr = weight.toFixed(Math.ceil(Math.log(1.0/unit.rounding) / Math.log(10) ));
- weightstr += ' ' + unit.name;
- return weightstr;
- },
- get_computed_price_string: function(){
- return this.format_currency(this.get_product_price() * this.weight);
- },
- close: function(){
- this._super();
- $('body').off('keypress',this.hotkey_handler);
-
- this.pos.proxy_queue.clear();
- },
-});
-gui.define_screen({name: 'scale', widget: ScaleScreenWidget});
-
-/*--------------------------------------*\
- | THE PRODUCT SCREEN |
-\*======================================*/
-
-// The product screen contains the list of products,
-// The category selector and the order display.
-// It is the default screen for orders and the
-// startup screen for shops.
-//
-// There product screens uses many sub-widgets,
-// the code follows.
-
-
-/* ------------ The Numpad ------------ */
-
-// The numpad that edits the order lines.
-
-var NumpadWidget = PosBaseWidget.extend({
- template:'NumpadWidget',
- init: function(parent) {
- this._super(parent);
- this.state = new models.NumpadState();
- },
- start: function() {
- this.applyAccessRights();
- this.state.bind('change:mode', this.changedMode, this);
- this.pos.bind('change:cashier', this.applyAccessRights, this);
- this.changedMode();
- this.$el.find('.numpad-backspace').click(_.bind(this.clickDeleteLastChar, this));
- this.$el.find('.numpad-minus').click(_.bind(this.clickSwitchSign, this));
- this.$el.find('.number-char').click(_.bind(this.clickAppendNewChar, this));
- this.$el.find('.mode-button').click(_.bind(this.clickChangeMode, this));
- },
- applyAccessRights: function() {
- var cashier = this.pos.get('cashier') || this.pos.get_cashier();
- var has_price_control_rights = !this.pos.config.restrict_price_control || cashier.role == 'manager';
- this.$el.find('.mode-button[data-mode="price"]')
- .toggleClass('disabled-mode', !has_price_control_rights)
- .prop('disabled', !has_price_control_rights);
- if (!has_price_control_rights && this.state.get('mode')=='price'){
- this.state.changeMode('quantity');
- }
- },
- clickDeleteLastChar: function() {
- return this.state.deleteLastChar();
- },
- clickSwitchSign: function() {
- return this.state.switchSign();
- },
- clickAppendNewChar: function(event) {
- var newChar;
- newChar = event.currentTarget.innerText || event.currentTarget.textContent;
- return this.state.appendNewChar(newChar);
- },
- clickChangeMode: function(event) {
- var newMode = event.currentTarget.attributes['data-mode'].nodeValue;
- return this.state.changeMode(newMode);
- },
- changedMode: function() {
- var mode = this.state.get('mode');
- $('.selected-mode').removeClass('selected-mode');
- $(_.str.sprintf('.mode-button[data-mode="%s"]', mode), this.$el).addClass('selected-mode');
- },
-});
-
-/* ---------- The Action Pad ---------- */
-
-// The action pad contains the payment button and the
-// customer selection button
-
-var ActionpadWidget = PosBaseWidget.extend({
- template: 'ActionpadWidget',
- init: function(parent, options) {
- var self = this;
- this._super(parent, options);
-
- this.pos.bind('change:selectedClient', function() {
- self.renderElement();
- });
- },
- renderElement: function() {
- var self = this;
- this._super();
- this.$('.pay').click(function(){
- var order = self.pos.get_order();
- var has_valid_product_lot = _.every(order.orderlines.models, function(line){
- return line.has_valid_product_lot();
- });
- if(!has_valid_product_lot){
- self.gui.show_popup('confirm',{
- 'title': _t('Empty Serial/Lot Number'),
- 'body': _t('One or more product(s) required serial/lot number.'),
- confirm: function(){
- self.gui.show_screen('payment');
- },
- });
- }else{
- self.gui.show_screen('payment');
- }
- });
- this.$('.set-customer').click(function(){
- self.gui.show_screen('clientlist');
- });
- }
-});
-
-/* --------- The Order Widget --------- */
-
-// Displays the current Order.
-
-var OrderWidget = PosBaseWidget.extend({
- template:'OrderWidget',
- init: function(parent, options) {
- var self = this;
- this._super(parent,options);
-
- this.numpad_state = options.numpad_state;
- this.numpad_state.reset();
- this.numpad_state.bind('set_value', this.set_value, this);
-
- this.pos.bind('change:selectedOrder', this.change_selected_order, this);
-
- this.line_click_handler = function(event){
- self.click_line(this.orderline, event);
- };
-
- if (this.pos.get_order()) {
- this.bind_order_events();
- }
-
- },
- click_line: function(orderline, event) {
- this.pos.get_order().select_orderline(orderline);
- this.numpad_state.reset();
- },
-
-
- set_value: function(val) {
- var order = this.pos.get_order();
- if (order.get_selected_orderline()) {
- var mode = this.numpad_state.get('mode');
- if( mode === 'quantity'){
- order.get_selected_orderline().set_quantity(val);
- }else if( mode === 'discount'){
- order.get_selected_orderline().set_discount(val);
- }else if( mode === 'price'){
- var selected_orderline = order.get_selected_orderline();
- selected_orderline.price_manually_set = true;
- selected_orderline.set_unit_price(val);
- }
- if (this.pos.config.iface_customer_facing_display) {
- this.pos.send_current_order_to_customer_facing_display();
- }
- }
- },
- change_selected_order: function() {
- if (this.pos.get_order()) {
- this.bind_order_events();
- this.numpad_state.reset();
- this.renderElement();
- }
- },
- orderline_add: function(){
- this.numpad_state.reset();
- this.renderElement('and_scroll_to_bottom');
- },
- orderline_remove: function(line){
- this.remove_orderline(line);
- this.numpad_state.reset();
- this.update_summary();
- },
- orderline_change: function(line){
- this.rerender_orderline(line);
- this.update_summary();
- },
- bind_order_events: function() {
- var order = this.pos.get_order();
- order.unbind('change:client', this.update_summary, this);
- order.bind('change:client', this.update_summary, this);
- order.unbind('change', this.update_summary, this);
- order.bind('change', this.update_summary, this);
-
- var lines = order.orderlines;
- lines.unbind('add', this.orderline_add, this);
- lines.bind('add', this.orderline_add, this);
- lines.unbind('remove', this.orderline_remove, this);
- lines.bind('remove', this.orderline_remove, this);
- lines.unbind('change', this.orderline_change, this);
- lines.bind('change', this.orderline_change, this);
-
- },
- render_orderline: function(orderline){
- var el_str = QWeb.render('Orderline',{widget:this, line:orderline});
- var el_node = document.createElement('div');
- el_node.innerHTML = _.str.trim(el_str);
- el_node = el_node.childNodes[0];
- el_node.orderline = orderline;
- el_node.addEventListener('click',this.line_click_handler);
- var el_lot_icon = el_node.querySelector('.line-lot-icon');
- if(el_lot_icon){
- el_lot_icon.addEventListener('click', (function() {
- this.show_product_lot(orderline);
- }.bind(this)));
- }
-
- orderline.node = el_node;
- return el_node;
- },
- remove_orderline: function(order_line){
- if(this.pos.get_order().get_orderlines().length === 0){
- this.renderElement();
- }else{
- order_line.node.parentNode.removeChild(order_line.node);
- }
- },
- rerender_orderline: function(order_line){
- var node = order_line.node;
- var replacement_line = this.render_orderline(order_line);
- node.parentNode.replaceChild(replacement_line,node);
- },
- // overriding the openerp framework replace method for performance reasons
- replace: function($target){
- this.renderElement();
- var target = $target[0];
- target.parentNode.replaceChild(this.el,target);
- },
- renderElement: function(scrollbottom){
- var order = this.pos.get_order();
- if (!order) {
- return;
- }
- var orderlines = order.get_orderlines();
-
- var el_str = QWeb.render('OrderWidget',{widget:this, order:order, orderlines:orderlines});
-
- var el_node = document.createElement('div');
- el_node.innerHTML = _.str.trim(el_str);
- el_node = el_node.childNodes[0];
-
-
- var list_container = el_node.querySelector('.orderlines');
- for(var i = 0, len = orderlines.length; i < len; i++){
- var orderline = this.render_orderline(orderlines[i]);
- list_container.appendChild(orderline);
- }
-
- if(this.el && this.el.parentNode){
- this.el.parentNode.replaceChild(el_node,this.el);
- }
- this.el = el_node;
- this.update_summary();
-
- if(scrollbottom){
- this.el.querySelector('.order-scroller').scrollTop = 100 * orderlines.length;
- }
- },
- update_summary: function(){
- var order = this.pos.get_order();
- if (!order.get_orderlines().length) {
- return;
- }
-
- var total = order ? order.get_total_with_tax() : 0;
- var taxes = order ? total - order.get_total_without_tax() : 0;
-
- this.el.querySelector('.summary .total > .value').textContent = this.format_currency(total);
- this.el.querySelector('.summary .total .subentry .value').textContent = this.format_currency(taxes);
- },
- show_product_lot: function(orderline){
- this.pos.get_order().select_orderline(orderline);
- var order = this.pos.get_order();
- order.display_lot_popup();
- },
-});
-
-/* ------ The Product Categories ------ */
-
-// Display and navigate the product categories.
-// Also handles searches.
-// - set_category() to change the displayed category
-// - reset_category() to go to the root category
-// - perform_search() to search for products
-// - clear_search() does what it says.
-
-var ProductCategoriesWidget = PosBaseWidget.extend({
- template: 'ProductCategoriesWidget',
- init: function(parent, options){
- var self = this;
- this._super(parent,options);
- this.onlyWeightable = options.onlyWeightable || false;
- this.category = this.pos.root_category;
- this.breadcrumb = [];
- this.subcategories = [];
- this.product_list_widget = options.product_list_widget || null;
- this.category_cache = new DomCache();
- this.start_categ_id = this.pos.config.iface_start_categ_id ? this.pos.config.iface_start_categ_id[0] : 0;
- this.set_category(this.pos.db.get_category_by_id(this.start_categ_id));
-
- this.switch_category_handler = function(event){
- self.set_category(self.pos.db.get_category_by_id(Number(this.dataset.categoryId)));
- self.renderElement();
- };
-
- this.clear_search_handler = function(event){
- self.clear_search();
- };
-
- var search_timeout = null;
- this.search_handler = function(event){
- if(event.type == "keypress" || event.keyCode === 46 || event.keyCode === 8){
- clearTimeout(search_timeout);
-
- var searchbox = this;
-
- search_timeout = setTimeout(function(){
- self.perform_search(self.category, searchbox.value, event.which === 13);
- },200);
- }
- };
- },
-
- // changes the category. if undefined, sets to root category
- set_category : function(category){
- var db = this.pos.db;
- if(!category){
- this.category = db.get_category_by_id(db.root_category_id);
- }else{
- this.category = category;
- }
- this.breadcrumb = [];
- var ancestors_ids = db.get_category_ancestors_ids(this.category.id);
- for(var i = 1; i < ancestors_ids.length; i++){
- this.breadcrumb.push(db.get_category_by_id(ancestors_ids[i]));
- }
- if(this.category.id !== db.root_category_id){
- this.breadcrumb.push(this.category);
- }
- this.subcategories = db.get_category_by_id(db.get_category_childs_ids(this.category.id));
- },
-
- get_image_url: function(category){
- return window.location.origin + '/web/image?model=pos.category&field=image_128&id='+category.id;
- },
-
- render_category: function( category, with_image ){
- var cached = this.category_cache.get_node(category.id);
- if(!cached){
- if(with_image){
- var image_url = this.get_image_url(category);
- var category_html = QWeb.render('CategoryButton',{
- widget: this,
- category: category,
- image_url: this.get_image_url(category),
- });
- category_html = _.str.trim(category_html);
- var category_node = document.createElement('div');
- category_node.innerHTML = category_html;
- category_node = category_node.childNodes[0];
- }else{
- var category_html = QWeb.render('CategorySimpleButton',{
- widget: this,
- category: category,
- });
- category_html = _.str.trim(category_html);
- var category_node = document.createElement('div');
- category_node.innerHTML = category_html;
- category_node = category_node.childNodes[0];
- }
- this.category_cache.cache_node(category.id,category_node);
- return category_node;
- }
- return cached;
- },
-
- replace: function($target){
- this.renderElement();
- var target = $target[0];
- target.parentNode.replaceChild(this.el,target);
- },
-
- renderElement: function(){
-
- var el_str = QWeb.render(this.template, {widget: this});
- var el_node = document.createElement('div');
-
- el_node.innerHTML = el_str;
- el_node = el_node.childNodes[1];
-
- if(this.el && this.el.parentNode){
- this.el.parentNode.replaceChild(el_node,this.el);
- }
-
- this.el = el_node;
-
- var withpics = this.pos.config.iface_display_categ_images;
-
- var list_container = el_node.querySelector('.category-list');
- if (list_container) {
- if (!withpics) {
- list_container.classList.add('simple');
- } else {
- list_container.classList.remove('simple');
- }
- for(var i = 0, len = this.subcategories.length; i < len; i++){
- list_container.appendChild(this.render_category(this.subcategories[i],withpics));
- }
- }
-
- var buttons = el_node.querySelectorAll('.js-category-switch');
- for(var i = 0; i < buttons.length; i++){
- buttons[i].addEventListener('click',this.switch_category_handler);
- }
-
- var products = this.pos.db.get_product_by_category(this.category.id);
- this.product_list_widget.set_product_list(products); // FIXME: this should be moved elsewhere ...
-
- this.el.querySelector('.searchbox input').addEventListener('keypress',this.search_handler);
-
- this.el.querySelector('.searchbox input').addEventListener('keydown',this.search_handler);
-
- this.el.querySelector('.search-clear.right').addEventListener('click',this.clear_search_handler);
-
- if(this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard){
- this.chrome.widget.keyboard.connect($(this.el.querySelector('.searchbox input')));
- }
- },
-
- // resets the current category to the root category
- reset_category: function(){
- this.set_category(this.pos.db.get_category_by_id(this.start_categ_id));
- this.renderElement();
- },
-
- // empties the content of the search box
- clear_search: function(){
- var products = this.pos.db.get_product_by_category(this.category.id);
- this.product_list_widget.set_product_list(products);
- var input = this.el.querySelector('.searchbox input');
- input.value = '';
- input.focus();
- },
- perform_search: function(category, query, buy_result){
- var products;
- if(query){
- products = this.pos.db.search_product_in_category(category.id,query);
- if(buy_result && products.length === 1){
- this.pos.get_order().add_product(products[0]);
- this.clear_search();
- }else{
- this.product_list_widget.set_product_list(products, query);
- }
- }else{
- products = this.pos.db.get_product_by_category(this.category.id);
- this.product_list_widget.set_product_list(products, query);
- }
- },
-
-});
-
-/* --------- The Product List --------- */
-
-// Display the list of products.
-// - change the list with .set_product_list()
-// - click_product_action(), passed as an option, tells
-// what to do when a product is clicked.
-
-var ProductListWidget = PosBaseWidget.extend({
- template:'ProductListWidget',
- init: function(parent, options) {
- var self = this;
- this._super(parent,options);
- this.model = options.model;
- this.productwidgets = [];
- this.weight = options.weight || 0;
- this.show_scale = options.show_scale || false;
- this.next_screen = options.next_screen || false;
- this.search_word = false;
-
- this.click_product_handler = function(){
- var product = self.pos.db.get_product_by_id(this.dataset.productId);
- options.click_product_action(product);
- };
-
- this.keypress_product_handler = function(ev){
- // React only to SPACE to avoid interfering with warcode scanner which sends ENTER
- if (ev.which != 32) {
- return;
- }
- ev.preventDefault();
- var product = self.pos.db.get_product_by_id(this.dataset.productId);
- options.click_product_action(product);
- };
-
- this.product_list = options.product_list || [];
- this.product_cache = new DomCache();
-
- this.pos.get('orders').bind('add remove change', function () {
- self.renderElement();
- }, this);
-
- this.pos.bind('change:selectedOrder', function () {
- this.renderElement();
- }, this);
- },
- set_product_list: function(product_list, search_word){
- this.product_list = product_list;
- this.search_word = !!search_word ? search_word : false;
- this.renderElement();
- },
- get_product_image_url: function(product){
- return window.location.origin + '/web/image?model=product.product&field=image_128&id='+product.id;
- },
- replace: function($target){
- this.renderElement();
- var target = $target[0];
- target.parentNode.replaceChild(this.el,target);
- },
- calculate_cache_key: function(product, pricelist){
- return product.id + ',' + pricelist.id;
- },
- _get_active_pricelist: function(){
- var current_order = this.pos.get_order();
- var current_pricelist = this.pos.default_pricelist;
-
- if (current_order) {
- current_pricelist = current_order.pricelist;
- }
-
- return current_pricelist;
- },
- render_product: function(product){
- var current_pricelist = this._get_active_pricelist();
- var cache_key = this.calculate_cache_key(product, current_pricelist);
- var cached = this.product_cache.get_node(cache_key);
- if(!cached){
- var image_url = this.get_product_image_url(product);
- var product_html = QWeb.render('Product',{
- widget: this,
- product: product,
- pricelist: current_pricelist,
- image_url: this.get_product_image_url(product),
- });
- var product_node = document.createElement('div');
- product_node.innerHTML = product_html;
- product_node = product_node.childNodes[1];
- this.product_cache.cache_node(cache_key,product_node);
- return product_node;
- }
- return cached;
- },
-
- renderElement: function() {
- var el_str = QWeb.render(this.template, {widget: this});
- var el_node = document.createElement('div');
- el_node.innerHTML = el_str;
- el_node = el_node.childNodes[1];
-
- if(this.el && this.el.parentNode){
- this.el.parentNode.replaceChild(el_node,this.el);
- }
- this.el = el_node;
-
- var list_container = el_node.querySelector('.product-list');
- for(var i = 0, len = this.product_list.length; i < len; i++){
- var product_node = this.render_product(this.product_list[i]);
- product_node.addEventListener('click',this.click_product_handler);
- product_node.addEventListener('keypress',this.keypress_product_handler);
- list_container.appendChild(product_node);
- }
- },
-});
-
-/* -------- The Action Buttons -------- */
-
-// Above the numpad and the actionpad, buttons
-// for extra actions and controls by point of
-// sale extensions modules.
-
-var action_button_classes = [];
-var define_action_button = function(classe, options){
- options = options || {};
-
- var classes = action_button_classes;
- var index = classes.length;
- var i;
-
- if (options.after) {
- for (i = 0; i < classes.length; i++) {
- if (classes[i].name === options.after) {
- index = i + 1;
- }
- }
- } else if (options.before) {
- for (i = 0; i < classes.length; i++) {
- if (classes[i].name === options.after) {
- index = i;
- break;
- }
- }
- }
- classes.splice(i,0,classe);
-};
-
-var ActionButtonWidget = PosBaseWidget.extend({
- template: 'ActionButtonWidget',
- label: _t('Button'),
- renderElement: function(){
- var self = this;
- this._super();
- this.$el.click(function(){
- self.button_click();
- });
- },
- button_click: function(){},
- highlight: function(highlight){
- this.$el.toggleClass('highlight',!!highlight);
- },
- // alternative highlight color
- altlight: function(altlight){
- this.$el.toggleClass('altlight',!!altlight);
- },
-});
-
-/* -------- The Product Screen -------- */
-
-var ProductScreenWidget = ScreenWidget.extend({
- template:'ProductScreenWidget',
-
- init: function() {
- this._super.apply(this, arguments);
- this.timeout = null;
- this.buffered_key_events = [];
- },
-
- start: function(){
-
- var self = this;
-
- this.actionpad = new ActionpadWidget(this,{});
- this.actionpad.replace(this.$('.placeholder-ActionpadWidget'));
-
- this.numpad = new NumpadWidget(this,{});
- this.numpad.replace(this.$('.placeholder-NumpadWidget'));
-
- this.order_widget = new OrderWidget(this,{
- numpad_state: this.numpad.state,
- });
- this.order_widget.replace(this.$('.placeholder-OrderWidget'));
-
- this.product_list_widget = new ProductListWidget(this,{
- click_product_action: function(product){ self.click_product(product); },
- product_list: this.pos.db.get_product_by_category(0)
- });
- this.product_list_widget.replace(this.$('.placeholder-ProductListWidget'));
-
- this.product_categories_widget = new ProductCategoriesWidget(this,{
- product_list_widget: this.product_list_widget,
- });
- this.product_categories_widget.replace(this.$('.placeholder-ProductCategoriesWidget'));
-
- this.action_buttons = {};
- var classes = action_button_classes;
- for (var i = 0; i < classes.length; i++) {
- var classe = classes[i];
- if ( !classe.condition || classe.condition.call(this) ) {
- var widget = new classe.widget(this,{});
- widget.appendTo(this.$('.control-buttons'));
- this.action_buttons[classe.name] = widget;
- }
- }
- if (_.size(this.action_buttons)) {
- this.$('.control-buttons').removeClass('oe_hidden');
- }
- this._onKeypadKeyDown = this._onKeypadKeyDown.bind(this);
- },
-
- click_product: function(product) {
- if(product.to_weight && this.pos.config.iface_electronic_scale){
- this.gui.show_screen('scale',{product: product});
- }else{
- this.pos.get_order().add_product(product);
- }
- },
-
- show: function(reset){
- this._super();
- if (reset) {
- this.product_categories_widget.reset_category();
- this.numpad.state.reset();
- }
- if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) {
- this.chrome.widget.keyboard.connect($(this.el.querySelector('.searchbox input')));
- }
- $(document).on('keydown.productscreen', this._onKeypadKeyDown);
- },
- close: function(){
- this._super();
- if(this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard){
- this.chrome.widget.keyboard.hide();
- }
- $(document).off('keydown.productscreen', this._onKeypadKeyDown);
- },
-
- /**
- * Buffers the key typed and distinguishes between actual keystrokes and
- * scanner inputs.
- *
- * @private
- * @param {event} ev - The keyboard event.
- */
- _onKeypadKeyDown: function (ev) {
- //prevent input and textarea keydown event
- if(!_.contains(["INPUT", "TEXTAREA"], $(ev.target).prop('tagName'))) {
- clearTimeout(this.timeout);
- this.buffered_key_events.push(ev);
- this.timeout = setTimeout(_.bind(this._handleBufferedKeys, this), BarcodeEvents.max_time_between_keys_in_ms);
- }
- },
-
- /**
- * Processes the buffer of keys filled by _onKeypadKeyDown and
- * distinguishes between the actual keystrokes and scanner inputs.
- *
- * @private
- */
- _handleBufferedKeys: function () {
- // If more than 2 keys are recorded in the buffer, chances are high that the input comes
- // from a barcode scanner. In this case, we don't do anything.
- if (this.buffered_key_events.length > 2) {
- this.buffered_key_events = [];
- return;
- }
-
- for (var i = 0; i < this.buffered_key_events.length; ++i) {
- var ev = this.buffered_key_events[i];
- if ((ev.key >= "0" && ev.key <= "9") || ev.key === ".") {
- this.numpad.state.appendNewChar(ev.key);
- }
- else {
- switch (ev.key){
- case "Backspace":
- this.numpad.state.deleteLastChar();
- break;
- case "Delete":
- this.numpad.state.resetValue();
- break;
- case ",":
- this.numpad.state.appendNewChar(".");
- break;
- case "+":
- this.numpad.state.positiveSign();
- break;
- case "-":
- this.numpad.state.negativeSign();
- break;
- }
- }
- }
- this.buffered_key_events = [];
- },
-});
-gui.define_screen({name:'products', widget: ProductScreenWidget});
-
-/*--------------------------------------*\
- | THE CLIENT LIST |
-\*======================================*/
-
-// The clientlist displays the list of customer,
-// and allows the cashier to create, edit and assign
-// customers.
-
-var ClientListScreenWidget = ScreenWidget.extend({
- template: 'ClientListScreenWidget',
-
- init: function(parent, options){
- this._super(parent, options);
- this.partner_cache = new DomCache();
- this.integer_client_details = ['country_id', 'state_id', 'property_product_pricelist'];
- },
-
- auto_back: true,
-
- show: function(){
- var self = this;
- this._super();
-
- this.renderElement();
- this.details_visible = false;
- this.old_client = this.pos.get_order().get_client();
-
- this.$('.back').click(function(){
- self.gui.back();
- });
-
- this.$('.next').click(function(){
- self.save_changes();
- self.gui.back(); // FIXME HUH ?
- });
-
- this.$('.new-customer').click(function(){
- self.display_client_details('edit',{
- 'country_id': self.pos.company.country_id,
- 'state_id': self.pos.company.state_id,
- });
- });
-
- var partners = this.pos.db.get_partners_sorted(1000);
- this.render_list(partners);
-
- this.reload_partners();
-
- if( this.old_client ){
- this.display_client_details('show',this.old_client,0);
- }
-
- this.$('.client-list-contents').on('click', '.client-line', function(event){
- self.line_select(event,$(this),parseInt($(this).data('id')));
- });
-
- var search_timeout = null;
-
- if(this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard){
- this.chrome.widget.keyboard.connect(this.$('.searchbox input'));
- }
-
- this.$('.searchbox input').on('keypress',function(event){
- clearTimeout(search_timeout);
-
- var searchbox = this;
-
- search_timeout = setTimeout(function(){
- self.perform_search(searchbox.value, event.which === 13);
- },70);
- });
-
- this.$('.searchbox .search-clear').click(function(){
- self.clear_search();
- });
- },
- hide: function () {
- this._super();
- this.new_client = null;
- },
- barcode_client_action: function(code){
- if (this.editing_client) {
- this.$('.detail.barcode').val(code.code);
- } else if (this.pos.db.get_partner_by_barcode(code.code)) {
- var partner = this.pos.db.get_partner_by_barcode(code.code);
- this.new_client = partner;
- this.display_client_details('show', partner);
- }
- },
- perform_search: function(query, associate_result){
- var customers;
- if(query){
- customers = this.pos.db.search_partner(query);
- this.display_client_details('hide');
- if ( associate_result && customers.length === 1){
- this.new_client = customers[0];
- this.save_changes();
- this.gui.back();
- }
- this.render_list(customers);
- }else{
- customers = this.pos.db.get_partners_sorted();
- this.render_list(customers);
- }
- },
- clear_search: function(){
- var customers = this.pos.db.get_partners_sorted(1000);
- this.render_list(customers);
- this.$('.searchbox input')[0].value = '';
- this.$('.searchbox input').focus();
- },
- render_list: function(partners){
- var contents = this.$el[0].querySelector('.client-list-contents');
- contents.innerHTML = "";
- for(var i = 0, len = Math.min(partners.length,1000); i < len; i++){
- var partner = partners[i];
- var clientline = this.partner_cache.get_node(partner.id);
- if(!clientline){
- var clientline_html = QWeb.render('ClientLine',{widget: this, partner:partners[i]});
- var clientline = document.createElement('tbody');
- clientline.innerHTML = clientline_html;
- clientline = clientline.childNodes[1];
- this.partner_cache.cache_node(partner.id,clientline);
- }
- if( partner === this.old_client ){
- clientline.classList.add('highlight');
- }else{
- clientline.classList.remove('highlight');
- }
- contents.appendChild(clientline);
- }
- },
- save_changes: function(){
- var order = this.pos.get_order();
- if( this.has_client_changed() ){
- var default_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.pos.config.default_fiscal_position_id[0]});
- if ( this.new_client ) {
- var client_fiscal_position_id;
- if (this.new_client.property_account_position_id ){
- client_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
- }
- order.fiscal_position = client_fiscal_position_id || default_fiscal_position_id;
- order.set_pricelist(_.findWhere(this.pos.pricelists, {'id': this.new_client.property_product_pricelist[0]}) || this.pos.default_pricelist);
- } else {
- order.fiscal_position = default_fiscal_position_id;
- order.set_pricelist(this.pos.default_pricelist);
- }
-
- order.set_client(this.new_client);
- }
- },
- has_client_changed: function(){
- if( this.old_client && this.new_client ){
- return this.old_client.id !== this.new_client.id;
- }else{
- return !!this.old_client !== !!this.new_client;
- }
- },
- toggle_save_button: function(){
- var $button = this.$('.button.next');
- if (this.editing_client) {
- $button.addClass('oe_hidden');
- return;
- } else if( this.new_client ){
- if( !this.old_client){
- $button.text(_t('Set Customer'));
- }else{
- $button.text(_t('Change Customer'));
- }
- }else{
- $button.text(_t('Deselect Customer'));
- }
- $button.toggleClass('oe_hidden',!this.has_client_changed());
- },
- line_select: function(event,$line,id){
- var partner = this.pos.db.get_partner_by_id(id);
- this.$('.client-list .lowlight').removeClass('lowlight');
- if ( $line.hasClass('highlight') ){
- $line.removeClass('highlight');
- $line.addClass('lowlight');
- this.display_client_details('hide',partner);
- this.new_client = null;
- this.toggle_save_button();
- }else{
- this.$('.client-list .highlight').removeClass('highlight');
- $line.addClass('highlight');
- var y = event.pageY - $line.parent().offset().top;
- this.display_client_details('show',partner,y);
- this.new_client = partner;
- this.toggle_save_button();
- }
- },
- partner_icon_url: function(id){
- return '/web/image?model=res.partner&id='+id+'&field=image_128';
- },
-
- // ui handle for the 'edit selected customer' action
- edit_client_details: function(partner) {
- this.display_client_details('edit',partner);
- },
-
- // ui handle for the 'cancel customer edit changes' action
- undo_client_details: function(partner) {
- if (!partner.id) {
- this.display_client_details('hide');
- } else {
- this.display_client_details('show',partner);
- }
- },
-
- // what happens when we save the changes on the client edit form -> we fetch the fields, sanitize them,
- // send them to the backend for update, and call saved_client_details() when the server tells us the
- // save was successfull.
- save_client_details: function(partner) {
- var self = this;
-
- var fields = {};
- this.$('.client-details-contents .detail').each(function(idx,el){
- if (self.integer_client_details.includes(el.name)){
- var parsed_value = parseInt(el.value, 10);
- if (isNaN(parsed_value)){
- fields[el.name] = false;
- }
- else{
- fields[el.name] = parsed_value
- }
- }
- else{
- fields[el.name] = el.value || false;
- }
- });
-
- if (!fields.name) {
- this.gui.show_popup('error',_t('A Customer Name Is Required'));
- return;
- }
-
- if (this.uploaded_picture) {
- fields.image_1920 = this.uploaded_picture;
- }
-
- fields.id = partner.id || false;
-
- var contents = this.$(".client-details-contents");
- contents.off("click", ".button.save");
-
-
- rpc.query({
- model: 'res.partner',
- method: 'create_from_ui',
- args: [fields],
- })
- .then(function(partner_id){
- self.saved_client_details(partner_id);
- }).catch(function(error){
- error.event.preventDefault();
- var error_body = _t('Your Internet connection is probably down.');
- if (error.message.data) {
- var except = error.message.data;
- error_body = except.arguments && except.arguments[0] || except.message || error_body;
- }
- self.gui.show_popup('error',{
- 'title': _t('Error: Could not Save Changes'),
- 'body': error_body,
- });
- contents.on('click','.button.save',function(){ self.save_client_details(partner); });
- });
- },
-
- // what happens when we've just pushed modifications for a partner of id partner_id
- saved_client_details: function (partner_id) {
- var self = this;
- var always = function () {
- $(".client-details-contents").on('click', '.button.save', function () {
- self.save_client_details(partner);
- });
- };
- return this.reload_partners().then( function() {
- var partner = self.pos.db.get_partner_by_id(partner_id);
- if (partner) {
- self.new_client = partner;
- self.toggle_save_button();
- self.display_client_details('show',partner);
- } else {
- // should never happen, because create_from_ui must return the id of the partner it
- // has created, and reload_partner() must have loaded the newly created partner.
- self.display_client_details('hide');
- }
- }).then(always, always);
- },
-
- // resizes an image, keeping the aspect ratio intact,
- // the resize is useful to avoid sending 12Mpixels jpegs
- // over a wireless connection.
- resize_image_to_dataurl: function(img, maxwidth, maxheight, callback){
- img.onload = function(){
- var canvas = document.createElement('canvas');
- var ctx = canvas.getContext('2d');
- var ratio = 1;
-
- if (img.width > maxwidth) {
- ratio = maxwidth / img.width;
- }
- if (img.height * ratio > maxheight) {
- ratio = maxheight / img.height;
- }
- var width = Math.floor(img.width * ratio);
- var height = Math.floor(img.height * ratio);
-
- canvas.width = width;
- canvas.height = height;
- ctx.drawImage(img,0,0,width,height);
-
- var dataurl = canvas.toDataURL();
- callback(dataurl);
- };
- },
-
- // Loads and resizes a File that contains an image.
- // callback gets a dataurl in case of success.
- load_image_file: function(file, callback){
- var self = this;
- if (!file.type.match(/image.*/)) {
- this.gui.show_popup('error',{
- title: _t('Unsupported File Format'),
- body: _t('Only web-compatible Image formats such as .png or .jpeg are supported'),
- });
- return;
- }
- utils.getDataURLFromFile(file).then(function (dataurl) {
- var img = new Image();
- img.src = dataurl;
- self.resize_image_to_dataurl(img,800,600,callback);
- }).guardedCatch(function () {
- self.gui.show_popup('error',{
- title :_t('Could Not Read Image'),
- body :_t('The provided file could not be read due to an unknown error'),
- });
- });
- },
-
- // This fetches partner changes on the server, and in case of changes,
- // rerenders the affected views
- reload_partners: function(){
- var self = this;
- return this.pos.load_new_partners().then(function(){
- // partners may have changed in the backend
- self.partner_cache = new DomCache();
-
- self.render_list(self.pos.db.get_partners_sorted(1000));
-
- // update the currently assigned client if it has been changed in db.
- var curr_client = self.pos.get_order().get_client();
- if (curr_client) {
- self.pos.get_order().set_client(self.pos.db.get_partner_by_id(curr_client.id));
- }
- });
- },
-
- // Shows,hides or edit the customer details box :
- // visibility: 'show', 'hide' or 'edit'
- // partner: the partner object to show or edit
- // clickpos: the height of the click on the list (in pixel), used
- // to maintain consistent scroll.
- display_client_details: function(visibility,partner,clickpos){
- var self = this;
- var searchbox = this.$('.searchbox input');
- var contents = this.$('.client-details-contents');
- var parent = this.$('.client-list').parent();
- var scroll = parent.scrollTop();
- var height = contents.height();
-
- contents.off('click','.button.edit');
- contents.off('click','.button.save');
- contents.off('click','.button.undo');
- contents.on('click','.button.edit',function(){ self.edit_client_details(partner); });
- contents.on('click','.button.save',function(){ self.save_client_details(partner); });
- contents.on('click','.button.undo',function(){ self.undo_client_details(partner); });
- this.editing_client = false;
- this.uploaded_picture = null;
-
- if(visibility === 'show'){
- contents.empty();
- contents.append($(QWeb.render('ClientDetails',{widget:this,partner:partner})));
-
- var new_height = contents.height();
-
- if(!this.details_visible){
- // resize client list to take into account client details
- parent.height('-=' + new_height);
-
- if(clickpos < scroll + new_height + 20 ){
- parent.scrollTop( clickpos - 20 );
- }else{
- parent.scrollTop(parent.scrollTop() + new_height);
- }
- }else{
- parent.scrollTop(parent.scrollTop() - height + new_height);
- }
-
- this.details_visible = true;
- this.toggle_save_button();
- } else if (visibility === 'edit') {
- // Connect the keyboard to the edited field
- if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) {
- contents.off('click', '.detail');
- searchbox.off('click');
- contents.on('click', '.detail', function(ev){
- self.chrome.widget.keyboard.connect(ev.target);
- self.chrome.widget.keyboard.show();
- });
- searchbox.on('click', function() {
- self.chrome.widget.keyboard.connect($(this));
- });
- }
-
- this.editing_client = true;
- contents.empty();
- contents.append($(QWeb.render('ClientDetailsEdit',{widget:this,partner:partner})));
- this.toggle_save_button();
-
- // Browsers attempt to scroll invisible input elements
- // into view (eg. when hidden behind keyboard). They don't
- // seem to take into account that some elements are not
- // scrollable.
- contents.find('input').blur(function() {
- setTimeout(function() {
- self.$('.window').scrollTop(0);
- }, 0);
- });
-
- contents.find('.client-address-country').on('change', function (ev) {
- var $stateSelection = contents.find('.client-address-states');
- var value = this.value;
- $stateSelection.empty()
- $stateSelection.append($("<option/>", {
- value: '',
- text: 'None',
- }));
- self.pos.states.forEach(function (state) {
- if (state.country_id[0] == value) {
- $stateSelection.append($("<option/>", {
- value: state.id,
- text: state.name
- }));
- }
- });
- });
-
- contents.find('.image-uploader').on('change',function(event){
- self.load_image_file(event.target.files[0],function(res){
- if (res) {
- contents.find('.client-picture img, .client-picture .fa').remove();
- contents.find('.client-picture').append("<img src='"+res+"'>");
- contents.find('.detail.picture').remove();
- self.uploaded_picture = res;
- }
- });
- });
- } else if (visibility === 'hide') {
- contents.empty();
- parent.height('100%');
- if( height > scroll ){
- contents.css({height:height+'px'});
- contents.animate({height:0},400,function(){
- contents.css({height:''});
- });
- }else{
- parent.scrollTop( parent.scrollTop() - height);
- }
- this.details_visible = false;
- this.toggle_save_button();
- }
- },
- close: function(){
- this._super();
- if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) {
- this.chrome.widget.keyboard.hide();
- }
- },
-});
-gui.define_screen({name:'clientlist', widget: ClientListScreenWidget});
-
-/*--------------------------------------*\
- | THE RECEIPT SCREEN |
-\*======================================*/
-
-// The receipt screen displays the order's
-// receipt and allows it to be printed in a web browser.
-// The receipt screen is not shown if the point of sale
-// is set up to print with the proxy. Altough it could
-// be useful to do so...
-
-var ReceiptScreenWidget = ScreenWidget.extend({
- template: 'ReceiptScreenWidget',
- show: function(){
- this._super();
- var self = this;
-
- this.render_change();
- this.render_receipt();
- this.handle_auto_print();
- },
- handle_auto_print: function() {
- if (this.should_auto_print() && !this.pos.get_order().is_to_email()) {
- this.print();
- if (this.should_close_immediately()){
- this.click_next();
- }
- } else {
- this.lock_screen(false);
- }
- },
- should_auto_print: function() {
- return this.pos.config.iface_print_auto && !this.pos.get_order()._printed;
- },
- should_close_immediately: function() {
- var order = this.pos.get_order();
- var invoiced_finalized = order.is_to_invoice() ? order.finalized : true;
- return this.pos.proxy.printer && this.pos.config.iface_print_skip_screen && invoiced_finalized;
- },
- lock_screen: function(locked) {
- this._locked = locked;
- if (locked) {
- this.$('.next').removeClass('highlight');
- } else {
- this.$('.next').addClass('highlight');
- }
- },
- get_receipt_render_env: function() {
- var order = this.pos.get_order();
- return {
- widget: this,
- pos: this.pos,
- order: order,
- receipt: order.export_for_printing(),
- orderlines: order.get_orderlines(),
- paymentlines: order.get_paymentlines(),
- };
- },
- print_web: function() {
- if ($.browser.safari) {
- document.execCommand('print', false, null);
- } else {
- try {
- window.print();
- } catch(err) {
- if (navigator.userAgent.toLowerCase().indexOf("android") > -1) {
- this.gui.show_popup('error',{
- 'title':_t('Printing is not supported on some android browsers'),
- 'body': _t('Printing is not supported on some android browsers due to no default printing protocol is available. It is possible to print your tickets by making use of an IoT Box.'),
- });
- } else {
- throw err;
- }
- }
- }
- this.pos.get_order()._printed = true;
- },
- print_html: function () {
- var receipt = QWeb.render('OrderReceipt', this.get_receipt_render_env());
-
- this.pos.proxy.printer.print_receipt(receipt);
- this.pos.get_order()._printed = true;
- },
- print: function() {
- var self = this;
-
- if (!this.pos.proxy.printer) { // browser (html) printing
-
- // The problem is that in chrome the print() is asynchronous and doesn't
- // execute until all rpc are finished. So it conflicts with the rpc used
- // to send the orders to the backend, and the user is able to go to the next
- // screen before the printing dialog is opened. The problem is that what's
- // printed is whatever is in the page when the dialog is opened and not when it's called,
- // and so you end up printing the product list instead of the receipt...
- //
- // Fixing this would need a re-architecturing
- // of the code to postpone sending of orders after printing.
- //
- // But since the print dialog also blocks the other asynchronous calls, the
- // button enabling in the setTimeout() is blocked until the printing dialog is
- // closed. But the timeout has to be big enough or else it doesn't work
- // 1 seconds is the same as the default timeout for sending orders and so the dialog
- // should have appeared before the timeout... so yeah that's not ultra reliable.
-
- this.lock_screen(true);
-
- setTimeout(function(){
- self.lock_screen(false);
- }, 1000);
-
- this.print_web();
- } else { // proxy (html) printing
- this.print_html();
- this.lock_screen(false);
- }
- },
- click_next: function() {
- this.pos.get_order().finalize();
- },
- click_back: function() {
- // Placeholder method for ReceiptScreen extensions that
- // can go back ...
- },
- renderElement: function() {
- var self = this;
- this._super();
- this.$('.next').click(function(){
- if (!self._locked) {
- self.click_next();
- }
- });
- this.$('.back').click(function(){
- if (!self._locked) {
- self.click_back();
- }
- });
- this.$('.button.print').click(function(){
- if (!self._locked) {
- self.print();
- }
- });
-
- },
- render_change: function() {
- var self = this;
- this.$('.change-value').html(this.format_currency(this.pos.get_order().get_change()));
- var order = this.pos.get_order();
- var order_screen_params = order.get_screen_data('params');
- var button_print_invoice = this.$('h2.print_invoice');
- if (order_screen_params && order_screen_params.button_print_invoice) {
- button_print_invoice.show();
- } else {
- button_print_invoice.hide();
- }
- },
- render_receipt: function() {
- this.$('.pos-receipt-container').html(QWeb.render('OrderReceipt', this.get_receipt_render_env()));
- },
-});
-gui.define_screen({name:'receipt', widget: ReceiptScreenWidget});
-
-/*--------------------------------------*\
- | THE PAYMENT SCREEN |
-\*======================================*/
-
-// The Payment Screen handles the payments, and
-// it is unfortunately quite complicated.
-
-var PaymentScreenWidget = ScreenWidget.extend({
- template: 'PaymentScreenWidget',
- back_screen: 'product',
- init: function(parent, options) {
- var self = this;
- this._super(parent, options);
-
- this.pos.bind('change:selectedOrder',function(){
- this.renderElement();
- this.watch_order_changes();
- },this);
- this.watch_order_changes();
-
- this.inputbuffer = "";
- this.firstinput = true;
- this.decimal_point = _t.database.parameters.decimal_point;
-
- // This is a keydown handler that prevents backspace from
- // doing a back navigation. It also makes sure that keys that
- // do not generate a keypress in Chrom{e,ium} (eg. delete,
- // backspace, ...) get passed to the keypress handler.
- this.keyboard_keydown_handler = function(event){
- if (event.keyCode === 8 || event.keyCode === 46) { // Backspace and Delete
- event.preventDefault();
-
- // These do not generate keypress events in
- // Chrom{e,ium}. Even if they did, we just called
- // preventDefault which will cancel any keypress that
- // would normally follow. So we call keyboard_handler
- // explicitly with this keydown event.
- self.keyboard_handler(event);
- }
- };
-
- // This keyboard handler listens for keypress events. It is
- // also called explicitly to handle some keydown events that
- // do not generate keypress events.
- this.keyboard_handler = function(event){
- // On mobile Chrome BarcodeEvents relies on an invisible
- // input being filled by a barcode device. Let events go
- // through when this input is focused.
- if (BarcodeEvents.$barcodeInput && BarcodeEvents.$barcodeInput.is(":focus")) {
- return;
- }
-
- var key = '';
-
- if (event.type === "keypress") {
- if (event.keyCode === 13) { // Enter
- self.validate_order();
- } else if ( event.keyCode === 190 || // Dot
- event.keyCode === 110 || // Decimal point (numpad)
- event.keyCode === 188 || // Comma
- event.keyCode === 46 ) { // Numpad dot
- key = self.decimal_point;
- } else if (event.keyCode >= 48 && event.keyCode <= 57) { // Numbers
- key = '' + (event.keyCode - 48);
- } else if (event.keyCode === 45) { // Minus
- key = '-';
- } else if (event.keyCode === 43) { // Plus
- key = '+';
- }
- } else { // keyup/keydown
- if (event.keyCode === 46) { // Delete
- key = 'CLEAR';
- } else if (event.keyCode === 8) { // Backspace
- key = 'BACKSPACE';
- }
- }
-
- self.payment_input(key);
- event.preventDefault();
- };
-
- this.pos.bind('change:selectedClient', function() {
- self.customer_changed();
- }, this);
- },
- // resets the current input buffer
- reset_input: function(){
- var line = this.pos.get_order().selected_paymentline;
- this.firstinput = true;
- if (line) {
- this.inputbuffer = this.format_currency_no_symbol(line.get_amount());
- } else {
- this.inputbuffer = "";
- }
- },
- // handle both keyboard and numpad input. Accepts
- // a string that represents the key pressed.
- payment_input: function(input) {
- var paymentline = this.pos.get_order().selected_paymentline;
-
- // disable changing amount on paymentlines with running or done payments on a payment terminal
- if (this.payment_interface && !['pending', 'retry'].includes(paymentline.get_payment_status())) {
- return;
- }
-
- var newbuf = this.gui.numpad_input(this.inputbuffer, input, {'firstinput': this.firstinput});
-
- this.firstinput = (newbuf.length === 0);
-
- // popup block inputs to prevent sneak editing.
- if (this.gui.has_popup()) {
- return;
- }
-
- if (newbuf !== this.inputbuffer) {
- this.inputbuffer = newbuf;
- var order = this.pos.get_order();
- if (paymentline) {
- var amount = this.inputbuffer;
-
- if (this.inputbuffer !== "-") {
- amount = field_utils.parse.float(this.inputbuffer);
- }
-
- paymentline.set_amount(amount);
- this.render_paymentlines();
- this.$('.paymentline.selected .edit').text(this.format_currency_no_symbol(amount));
- }
- }
- },
- click_numpad: function(button) {
- var paymentlines = this.pos.get_order().get_paymentlines();
- var open_paymentline = false;
-
- for (var i = 0; i < paymentlines.length; i++) {
- if (! paymentlines[i].paid) {
- open_paymentline = true;
- }
- }
-
- if (! open_paymentline) {
- this.pos.get_order().add_paymentline( this.pos.payment_methods[0]);
- this.render_paymentlines();
- }
-
- this.payment_input(button.data('action'));
- },
- render_numpad: function() {
- var self = this;
- var numpad = $(QWeb.render('PaymentScreen-Numpad', { widget:this }));
- numpad.on('click','button',function(){
- self.click_numpad($(this));
- });
- return numpad;
- },
- click_delete_paymentline: function(cid){
- var lines = this.pos.get_order().get_paymentlines();
- for ( var i = 0; i < lines.length; i++ ) {
- var line = lines[i];
- if (line.cid === cid) {
- // If a paymentline with a payment terminal linked to
- // it is removed, the terminal should get a cancel
- // request.
- if (['waiting', 'waitingCard', 'timeout'].includes(lines[i].get_payment_status())) {
- line.payment_method.payment_terminal.send_payment_cancel(this.pos.get_order(), cid);
- }
-
- this.pos.get_order().remove_paymentline(line);
- this.reset_input();
- this.render_paymentlines();
- return;
- }
- }
- },
- click_paymentline: function(cid){
- var lines = this.pos.get_order().get_paymentlines();
- for ( var i = 0; i < lines.length; i++ ) {
- if (lines[i].cid === cid) {
- this.pos.get_order().select_paymentline(lines[i]);
- this.reset_input();
- this.render_paymentlines();
- return;
- }
- }
- },
- /**
- * link the proper functions to buttons for payment terminals
- * send_payment_request, force_payment_done and cancel_payment.
- */
- render_payment_terminal: function() {
- var self = this;
- var order = this.pos.get_order();
- if (!order) {
- return;
- }
-
- this.$el.find('.send_payment_request').click(function () {
- var cid = $(this).data('cid');
- // Other payment lines can not be reversed anymore
- order.get_paymentlines().forEach(function (line) {
- line.can_be_reversed = false;
- });
-
- var line = self.pos.get_order().get_paymentline(cid);
- var payment_terminal = line.payment_method.payment_terminal;
- line.set_payment_status('waiting');
- self.render_paymentlines();
-
- payment_terminal.send_payment_request(cid).then(function (payment_successful) {
- if (payment_successful) {
- line.set_payment_status('done');
- line.can_be_reversed = self.payment_interface.supports_reversals;
- self.reset_input(); // in case somebody entered a tip the amount tendered should be updated
- } else {
- line.set_payment_status('retry');
- }
- }).finally(function () {
- self.render_paymentlines();
- });
-
- self.render_paymentlines();
- });
- this.$el.find('.send_payment_cancel').click(function () {
- var cid = $(this).data('cid');
- var line = self.pos.get_order().get_paymentline($(this).data('cid'));
- var payment_terminal = line.payment_method.payment_terminal;
- line.set_payment_status('waitingCancel');
- self.render_paymentlines();
-
- payment_terminal.send_payment_cancel(self.pos.get_order(), cid).finally(function () {
- line.set_payment_status('retry');
- self.render_paymentlines();
- });
-
- self.render_paymentlines();
- });
- this.$el.find('.send_payment_reversal').click(function () {
- var cid = $(this).data('cid');
- var line = self.pos.get_order().get_paymentline($(this).data('cid'));
- var payment_terminal = line.payment_method.payment_terminal;
- line.set_payment_status('reversing');
- self.render_paymentlines();
-
- payment_terminal.send_payment_reversal(cid).then(function (reversal_successful) {
- if (reversal_successful) {
- line.set_amount(0);
- line.set_payment_status('reversed');
- } else {
- line.can_be_reversed = false;
- line.set_payment_status('done');
- }
- self.render_paymentlines();
- });
- });
-
- this.$el.find('.send_force_done').click(function () {
- var line = self.pos.get_order().get_paymentline($(this).data('cid'));
- var payment_terminal = line.payment_method.payment_terminal;
- line.set_payment_status('done');
- self.render_paymentlines();
- });
- },
- render_paymentlines: function() {
- var self = this;
- var order = this.pos.get_order();
- if (!order) {
- return;
- }
-
- var lines = order.get_paymentlines();
- var extradue = this.compute_extradue(order);
-
- this.$('.paymentlines-container').empty();
- var lines = $(QWeb.render('PaymentScreen-Paymentlines', {
- widget: this,
- order: order,
- paymentlines: lines,
- extradue: extradue,
- }));
-
- lines.on('click','.delete-button',function(){
- self.click_delete_paymentline($(this).data('cid'));
- });
-
- lines.on('click','.paymentline',function(){
- self.click_paymentline($(this).data('cid'));
- });
-
- lines.appendTo(this.$('.paymentlines-container'));
-
- this.render_payment_terminal();
- },
- compute_extradue: function (order) {
- var lines = order.get_paymentlines();
- var due = order.get_due();
- if (due && lines.length && (due !== order.get_due(lines[lines.length-1]) || lines[lines.length - 1].payment_status === 'reversed')) {
- return due;
- }
- return 0;
- },
- click_paymentmethods: function(id) {
- var payment_method = this.pos.payment_methods_by_id[id];
- var order = this.pos.get_order();
-
- if (order.electronic_payment_in_progress()) {
- this.gui.show_popup('error',{
- 'title': _t('Error'),
- 'body': _t('There is already an electronic payment in progress.'),
- });
- return false;
- } else {
- order.add_paymentline(payment_method);
- this.reset_input();
-
- this.payment_interface = payment_method.payment_terminal;
- if (this.payment_interface) {
- order.selected_paymentline.set_payment_status('pending');
- }
-
- this.render_paymentlines();
- }
- return true;
- },
- render_paymentmethods: function() {
- var self = this;
- var methods = $(QWeb.render('PaymentScreen-Paymentmethods', { widget:this }));
- methods.on('click','.paymentmethod',function(){
- self.click_paymentmethods($(this).data('id'));
- });
- return methods;
- },
- click_invoice: function(){
- var order = this.pos.get_order();
- order.set_to_invoice(!order.is_to_invoice());
- if (order.is_to_invoice()) {
- this.$('.js_invoice').addClass('highlight');
- } else {
- this.$('.js_invoice').removeClass('highlight');
- }
- },
- click_email: function(){
- var order = this.pos.get_order();
- order.set_to_email(!order.is_to_email());
- this.$('.js_email').toggleClass('highlight', order.is_to_email());
- },
- click_tip: function(){
- var self = this;
- var order = this.pos.get_order();
- var tip = order.get_tip();
- var change = order.get_change();
- var value = tip;
-
- if (tip === 0 && change > 0 ) {
- value = change;
- }
-
- this.gui.show_popup('number',{
- 'title': tip ? _t('Change Tip') : _t('Add Tip'),
- 'value': self.format_currency_no_symbol(value),
- 'confirm': function(value) {
- order.set_tip(field_utils.parse.float(value));
- self.render_paymentlines();
- }
- });
- },
- customer_changed: function() {
- var client = this.pos.get_client();
- this.$('.js_customer_name').text( client ? client.name : _t('Customer') );
- },
- click_set_customer: function(){
- this.gui.show_screen('clientlist');
- },
- click_back: function(){
- this.gui.show_screen('products');
- },
- renderElement: function() {
- var self = this;
- this._super();
-
- var numpad = this.render_numpad();
- numpad.appendTo(this.$('.payment-numpad'));
-
- var methods = this.render_paymentmethods();
- methods.appendTo(this.$('.paymentmethods-container'));
-
- this.render_paymentlines();
-
- this.$('.back').click(function(){
- self.click_back();
- });
-
- this.$('.next').click(function(){
- self.validate_order();
- });
-
- this.$('.js_set_customer').click(function(){
- self.click_set_customer();
- });
-
- this.$('.js_tip').click(function(){
- self.click_tip();
- });
- this.$('.js_invoice').click(function(){
- self.click_invoice();
- });
- this.$('.js_email').click(function(){
- self.click_email();
- });
- this.$('.js_cashdrawer').click(function(){
- self.pos.proxy.printer.open_cashbox();
- });
-
- },
- show: function(){
- this.pos.get_order().clean_empty_paymentlines();
- this.reset_input();
- this.render_paymentlines();
- // that one comes from BarcodeEvents
- $('body').keypress(this.keyboard_handler);
- // that one comes from the pos, but we prefer to cover all the basis
- $('body').keydown(this.keyboard_keydown_handler);
- this._super();
- },
- hide: function(){
- $('body').off('keypress', this.keyboard_handler);
- $('body').off('keydown', this.keyboard_keydown_handler);
- var order = this.pos.get_order();
- if (order) {
- order.stop_electronic_payment();
- }
- this._super();
- },
- // sets up listeners to watch for order changes
- watch_order_changes: function() {
- var self = this;
- var order = this.pos.get_order();
- if(this.old_order){
- this.old_order.stop_electronic_payment();
- this.old_order.unbind(null, null, this);
- this.old_order.paymentlines.unbind(null, null, this);
- }
- if (!order) {
- return;
- }
- order.bind('all',function(){
- self.order_changes();
- });
- order.paymentlines.bind('all', self.order_changes.bind(self));
- this.old_order = order;
- },
- // called when the order is changed, used to show if
- // the order is paid or not
- order_changes: function(){
- var self = this;
- var order = this.pos.get_order();
- if (!order) {
- return;
- } else if (order.is_paid()) {
- self.$('.next').addClass('highlight');
- }else{
- self.$('.next').removeClass('highlight');
- }
- },
-
- order_is_valid: function(force_validation) {
- var self = this;
- var order = this.pos.get_order();
-
- // FIXME: this check is there because the backend is unable to
- // process empty orders. This is not the right place to fix it.
- if (order.get_orderlines().length === 0) {
- this.gui.show_popup('error',{
- 'title': _t('Empty Order'),
- 'body': _t('There must be at least one product in your order before it can be validated'),
- });
- return false;
- }
-
- if (!order.is_paid() || this.invoicing) {
- return false;
- }
-
- if(order.has_not_valid_rounding()) {
- var line = order.has_not_valid_rounding();
- this.gui.show_popup('error',{
- title: _t('Incorrect rounding'),
- body: _t('You have to round your payments lines.' + line.amount + ' is not rounded.'),
- });
- return false;
- }
-
- // The exact amount must be paid if there is no cash payment method defined.
- if (Math.abs(order.get_total_with_tax() - order.get_total_paid() + order.get_rounding_applied()) > 0.00001) {
- var cash = false;
- for (var i = 0; i < this.pos.payment_methods.length; i++) {
- cash = cash || (this.pos.payment_methods[i].is_cash_count);
- }
- if (!cash) {
- this.gui.show_popup('error',{
- title: _t('Cannot return change without a cash payment method'),
- body: _t('There is no cash payment method available in this point of sale to handle the change.\n\n Please pay the exact amount or add a cash payment method in the point of sale configuration'),
- });
- return false;
- }
- }
-
- var client = order.get_client();
- if (order.is_to_email() && (!client || client && !utils.is_email(client.email))) {
- var title = !client
- ? 'Please select the customer'
- : 'Please provide valid email';
- var body = !client
- ? 'You need to select the customer before you can send the receipt via email.'
- : 'This customer does not have a valid email address, define one or do not send an email.';
- this.gui.show_popup('confirm', {
- 'title': _t(title),
- 'body': _t(body),
- confirm: function () {
- this.gui.show_screen('clientlist');
- },
- });
- return false;
- }
-
- // if the change is too large, it's probably an input error, make the user confirm.
- if (!force_validation && order.get_total_with_tax() > 0 && (order.get_total_with_tax() * 1000 < order.get_total_paid())) {
- this.gui.show_popup('confirm',{
- title: _t('Please Confirm Large Amount'),
- body: _t('Are you sure that the customer wants to pay') +
- ' ' +
- this.format_currency(order.get_total_paid()) +
- ' ' +
- _t('for an order of') +
- ' ' +
- this.format_currency(order.get_total_with_tax()) +
- ' ' +
- _t('? Clicking "Confirm" will validate the payment.'),
- confirm: function() {
- self.validate_order('confirm');
- },
- });
- return false;
- }
-
- return true;
- },
-
- finalize_validation: function() {
- var self = this;
- var order = this.pos.get_order();
-
- if (order.is_paid_with_cash() && this.pos.config.iface_cashdrawer) {
-
- this.pos.proxy.printer.open_cashbox();
- }
-
- order.initialize_validation_date();
- order.finalized = true;
-
- if (order.is_to_invoice()) {
- var invoiced = this.pos.push_and_invoice_order(order);
- this.invoicing = true;
-
- invoiced.catch(this._handleFailedPushForInvoice.bind(this, order, false));
-
- invoiced.then(function (server_ids) {
- self.invoicing = false;
- var post_push_promise = [];
- post_push_promise = self.post_push_order_resolve(order, server_ids);
- post_push_promise.then(function () {
- self.gui.show_screen('receipt');
- }).catch(function (error) {
- self.gui.show_screen('receipt');
- if (error) {
- self.gui.show_popup('error',{
- 'title': "Error: no internet connection",
- 'body': error,
- });
- }
- });
- });
- } else {
- var ordered = this.pos.push_order(order);
- if (order.wait_for_push_order()){
- var server_ids = [];
- ordered.then(function (ids) {
- server_ids = ids;
- }).finally(function() {
- var post_push_promise = [];
- post_push_promise = self.post_push_order_resolve(order, server_ids);
- post_push_promise.then(function () {
- self.gui.show_screen('receipt');
- }).catch(function (error) {
- self.gui.show_screen('receipt');
- if (error) {
- self.gui.show_popup('error',{
- 'title': "Error: no internet connection",
- 'body': error,
- });
- }
- });
- });
- }
- else {
- self.gui.show_screen('receipt');
- }
-
- }
-
- },
- post_push_order_resolve: function(order, server_ids){
- var self = this;
- if (order.is_to_email()) {
- var email_promise = self.send_receipt_to_customer(server_ids);
- return email_promise;
- }
- else {
- return Promise.resolve();
- }
- },
-
- // Check if the order is paid, then sends it to the backend,
- // and complete the sale process
- validate_order: function(force_validation) {
- if (this.order_is_valid(force_validation)) {
- // remove pending payments before finalizing the validation
- var order = this.pos.get_order();
- order.get_paymentlines().forEach(line => {
- if (!line.is_done()) {
- order.remove_paymentline(line);
- }
- });
- this.render_paymentlines();
- this.finalize_validation();
- }
- },
-
- send_receipt_to_customer: function(order_server_ids) {
- var order = this.pos.get_order();
- var data = {
- widget: this,
- pos: order.pos,
- order: order,
- receipt: order.export_for_printing(),
- orderlines: order.get_orderlines(),
- paymentlines: order.get_paymentlines(),
- };
-
- var receipt = QWeb.render('OrderReceipt', data);
- var printer = new Printer();
-
- return new Promise(function (resolve, reject) {
- printer.htmlToImg(receipt).then(function(ticket) {
- rpc.query({
- model: 'pos.order',
- method: 'action_receipt_to_customer',
- args: [order_server_ids, order.get_name(), order.get_client(), ticket],
- }).then(function() {
- resolve();
- }).catch(function () {
- order.set_to_email(false);
- reject("There is no internet connection, impossible to send the email.");
- });
- });
- });
- },
-});
-gui.define_screen({name:'payment', widget: PaymentScreenWidget});
-
-var set_fiscal_position_button = ActionButtonWidget.extend({
- template: 'SetFiscalPositionButton',
- init: function (parent, options) {
- this._super(parent, options);
-
- this.pos.get('orders').bind('add remove change', function () {
- this.renderElement();
- }, this);
-
- this.pos.bind('change:selectedOrder', function () {
- this.renderElement();
- }, this);
- },
- button_click: function () {
- var self = this;
-
- var no_fiscal_position = [{
- label: _t("None"),
- }];
- var fiscal_positions = _.map(self.pos.fiscal_positions, function (fiscal_position) {
- return {
- label: fiscal_position.name,
- item: fiscal_position
- };
- });
-
- var selection_list = no_fiscal_position.concat(fiscal_positions);
- self.gui.show_popup('selection',{
- title: _t('Select Fiscal Position'),
- list: selection_list,
- confirm: function (fiscal_position) {
- var order = self.pos.get_order();
- order.fiscal_position = fiscal_position;
- // This will trigger the recomputation of taxes on order lines.
- // It is necessary to manually do it for the sake of consistency
- // with what happens when changing a customer.
- _.each(order.orderlines.models, function (line) {
- line.set_quantity(line.quantity);
- });
- order.trigger('change');
- },
- is_selected: function (fiscal_position) {
- return fiscal_position === self.pos.get_order().fiscal_position;
- }
- });
- },
- get_current_fiscal_position_name: function () {
- var name = _t('Tax');
- var order = this.pos.get_order();
-
- if (order) {
- var fiscal_position = order.fiscal_position;
-
- if (fiscal_position) {
- name = fiscal_position.display_name;
- }
- }
- return name;
- },
-});
-
-define_action_button({
- 'name': 'set_fiscal_position',
- 'widget': set_fiscal_position_button,
- 'condition': function(){
- return this.pos.fiscal_positions.length > 0;
- },
-});
-
-var set_pricelist_button = ActionButtonWidget.extend({
- template: 'SetPricelistButton',
- init: function (parent, options) {
- this._super(parent, options);
-
- this.pos.get('orders').bind('add remove change', function () {
- this.renderElement();
- }, this);
-
- this.pos.bind('change:selectedOrder', function () {
- this.renderElement();
- }, this);
- },
- button_click: function () {
- var self = this;
-
- var pricelists = _.map(self.pos.pricelists, function (pricelist) {
- return {
- label: pricelist.name,
- item: pricelist
- };
- });
-
- self.gui.show_popup('selection',{
- title: _t('Select pricelist'),
- list: pricelists,
- confirm: function (pricelist) {
- var order = self.pos.get_order();
- order.set_pricelist(pricelist);
- },
- is_selected: function (pricelist) {
- return pricelist.id === self.pos.get_order().pricelist.id;
- }
- });
- },
- get_current_pricelist_name: function () {
- var name = _t('Pricelist');
- var order = this.pos.get_order();
-
- if (order) {
- var pricelist = order.pricelist;
-
- if (pricelist) {
- name = pricelist.display_name;
- }
- }
- return name;
- },
-});
-
-define_action_button({
- 'name': 'set_pricelist',
- 'widget': set_pricelist_button,
- 'condition': function(){
- return this.pos.config.use_pricelist && this.pos.pricelists.length > 1;
- },
-});
-
-return {
- ReceiptScreenWidget: ReceiptScreenWidget,
- ActionButtonWidget: ActionButtonWidget,
- define_action_button: define_action_button,
- ScreenWidget: ScreenWidget,
- PaymentScreenWidget: PaymentScreenWidget,
- OrderWidget: OrderWidget,
- NumpadWidget: NumpadWidget,
- ProductScreenWidget: ProductScreenWidget,
- ProductListWidget: ProductListWidget,
- ClientListScreenWidget: ClientListScreenWidget,
- ActionpadWidget: ActionpadWidget,
- DomCache: DomCache,
- ProductCategoriesWidget: ProductCategoriesWidget,
- ScaleScreenWidget: ScaleScreenWidget,
- set_fiscal_position_button: set_fiscal_position_button,
- set_pricelist_button: set_pricelist_button,
-};
-
-});
diff --git a/addons/point_of_sale/static/src/js/utils.js b/addons/point_of_sale/static/src/js/utils.js
new file mode 100644
index 00000000000..ae698ab60d3
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/utils.js
@@ -0,0 +1,21 @@
+odoo.define('point_of_sale.utils', function(require) {
+ 'use strict';
+
+ function getFileAsText(file) {
+ return new Promise((resolve, reject) => {
+ if (!file) {
+ reject();
+ } else {
+ const reader = new FileReader();
+ reader.addEventListener('load', function() {
+ resolve(reader.result);
+ });
+ reader.addEventListener('abort', reject);
+ reader.addEventListener('error', reject);
+ reader.readAsText(file);
+ }
+ });
+ }
+
+ return { getFileAsText };
+});
diff --git a/addons/point_of_sale/static/src/js/widget_base.js b/addons/point_of_sale/static/src/js/widget_base.js
deleted file mode 100644
index c81d2d13f8a..00000000000
--- a/addons/point_of_sale/static/src/js/widget_base.js
+++ /dev/null
@@ -1,83 +0,0 @@
-odoo.define('point_of_sale.BaseWidget', function (require) {
-"use strict";
-
-var field_utils = require('web.field_utils');
-var utils = require('web.utils');
-var Widget = require('web.Widget');
-
-var round_di = utils.round_decimals;
-
-// This is a base class for all Widgets in the POS. It exposes relevant data to the
-// templates :
-// - widget.currency : { symbol: '$' | '€' | ..., position: 'before' | 'after }
-// - widget.format_currency(amount) : this method returns a formatted string based on the
-// symbol, the position, and the amount of money.
-// if the PoS is not fully loaded when you instanciate the widget, the currency might not
-// yet have been initialized. Use __build_currency_template() to recompute with correct values
-// before rendering.
-var PosBaseWidget = Widget.extend({
- init:function(parent,options){
- this._super(parent);
- options = options || {};
- this.pos = options.pos || (parent ? parent.pos : undefined);
- this.chrome = options.chrome || (parent ? parent.chrome : undefined);
- this.gui = options.gui || (parent ? parent.gui : undefined);
-
- // the widget class does not support anymore using $el/el before the
- // 'start' lifecycle method, but point of sale actually needs it.
- this.setElement(this._makeDescriptive());
- },
- format_currency: function(amount,precision){
- var currency = (this.pos && this.pos.currency) ? this.pos.currency : {symbol:'$', position: 'after', rounding: 0.01, decimals: 2};
-
- amount = this.format_currency_no_symbol(amount,precision);
-
- if (currency.position === 'after') {
- return amount + ' ' + (currency.symbol || '');
- } else {
- return (currency.symbol || '') + ' ' + amount;
- }
- },
- format_currency_no_symbol: function(amount, precision) {
- var currency = (this.pos && this.pos.currency) ? this.pos.currency : {symbol:'$', position: 'after', rounding: 0.01, decimals: 2};
- var decimals = currency.decimals;
-
- if (precision && this.pos.dp[precision] !== undefined) {
- decimals = this.pos.dp[precision];
- }
-
- if (typeof amount === 'number') {
- amount = round_di(amount,decimals).toFixed(decimals);
- amount = field_utils.format.float(round_di(amount, decimals), {digits: [69, decimals]});
- }
-
- return amount;
- },
- show: function(){
- this.$el.removeClass('oe_hidden');
- },
- hide: function(){
- this.$el.addClass('oe_hidden');
- },
- format_pr: function(value,precision){
- var decimals = precision > 0 ? Math.max(0,Math.ceil(Math.log(1.0/precision) / Math.log(10))) : 0;
- return value.toFixed(decimals);
- },
- format_fixed: function(value,integer_width,decimal_width){
- value = value.toFixed(decimal_width || 0);
- var width = value.indexOf('.');
- if (width < 0 ) {
- width = value.length;
- }
- var missing = integer_width - width;
- while (missing > 0) {
- value = '0' + value;
- missing--;
- }
- return value;
- },
-});
-
-return PosBaseWidget;
-
-});
diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml
new file mode 100644
index 00000000000..bf3d483fd56
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Chrome.xml
@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="Chrome" owl="1">
+ <div class="pos" t-att-class="{ 'big-scrollbars': state.uiState.hasBigScrollBars }">
+ <div class="pos-receipt-print"></div>
+ <t t-if="state.uiState === 'READY'">
+ <div class="pos-topheader">
+ <div t-if="tempScreen.isShown" class="block-top-header" />
+ <div class="pos-branding">
+ <img class="pos-logo" t-on-click="trigger('toggle-debug-widget')"
+ src="/point_of_sale/static/src/img/logo.png" alt="Logo" />
+ </div>
+ <div class="pos-rightheader">
+ <OrderSelector />
+ <CashierName />
+ <SaleDetailsButton t-if="env.pos.proxy.printer" />
+ <ProxyStatus t-if="env.pos.config.use_proxy" />
+ <ClientScreenButton t-if="clientScreenButtonIsShown" />
+ <SyncNotification />
+ <HeaderButton />
+ </div>
+ </div>
+ <div class="pos-content">
+ <div class="window">
+ <div class="subwindow">
+ <div class="subwindow-container">
+ <div class="subwindow-container-fix screens">
+ <t isShown="!tempScreen.isShown" t-component="mainScreen.component"
+ t-props="mainScreenProps" t-key="mainScreen.name" />
+ <t t-if="tempScreen.isShown" t-component="tempScreen.component"
+ t-props="tempScreenProps" t-key="tempScreen.name" />
+ </div>
+ </div>
+ </div>
+ </div>
+ <DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown"
+ t-transition="fade" />
+ </div>
+ </t>
+
+ <div t-if="['LOADING', 'CLOSING'].includes(state.uiState)" class="loader" t-transition="swing">
+ <div class="loader-feedback">
+ <h1 class="message">
+ <t t-esc="loading.message" />
+ </h1>
+ <div class="progressbar">
+ <div class="progress" t-ref="progressbar"></div>
+ </div>
+ <div t-if="loading.skipButtonIsShown" class="button skip" t-on-click="trigger('loading-skip-callback')">
+ Skip
+ </div>
+ </div>
+ </div>
+
+ <!-- Allow popups to be visible at any state of the ui. -->
+ <div t-if="popup.isShown" class="popups">
+ <t t-component="popup.component" t-props="popupProps"
+ t-key="popup.name" />
+ </div>
+
+ <NotificationSound t-if="state.sound.src" sound="state.sound" />
+ </div>
+ </t>
+
+ <t t-name="CustomerFacingDisplayHead">
+ <div class="resources">
+ <base t-att-href="origin" />
+ <link href="/point_of_sale/static/src/css/customer_facing_display.css"
+ rel="stylesheet" />
+ <script type="text/javascript">
+ // This function needs to be named that way, call it the foreign JS API
+ // The iotbox will execute it, with the behavior intended
+ function foreign_js() {
+ if ($('.pos-adv').hasClass('pos-hidden')) {
+ $('.pos-customer_facing_display').addClass('pos-js_no_ADV');
+ }
+ $(window).on('resize', function () {
+ $('.pos-customer_facing_display').toggleClass('pos-js_no_ADV', $('.pos-adv').hasClass('pos-hidden'));
+ }).trigger('resize');
+ };
+ </script>
+ </div>
+ </t>
+
+ <t t-name="CustomerFacingDisplayOrderLines">
+ <t t-foreach="orderlines" t-as="orderline">
+ <div class="pos_orderlines_item">
+ <div>
+ <div t-attf-style="background-image:url(#{orderline.product.image_base64})" />
+ </div>
+ <div>
+ <t t-esc="orderline.product.display_name" />
+ </div>
+ <div>
+ <t t-esc="orderline.get_quantity_str()" />
+ </div>
+ <div>
+ <t t-esc="pos.format_currency(orderline.get_display_price())" />
+ </div>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="CustomerFacingDisplayPaymentLines">
+ <t t-foreach="order.get_paymentlines()" t-as="paymentline">
+ <div>
+ <span>
+ <t t-esc="paymentline.name" /></span>
+ </div>
+ <div>
+ <span>
+ <t t-esc="pos.format_currency(paymentline.get_amount())" /></span>
+ </div>
+ </t>
+ <t t-if="order.get_paymentlines().length > 0">
+ <div>
+ <span class="pos-change_title">Change:</span>
+ </div>
+ <div>
+ <span class="pos-change_amount">
+ <t t-esc="pos.format_currency(order.get_change())" /></span>
+ </div>
+ </t>
+ </t>
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml
new file mode 100644
index 00000000000..41b7ee69d82
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CashierName" owl="1">
+ <div class="oe_status">
+ <span class="username">
+ <t t-esc="username" />
+ </span>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml
new file mode 100644
index 00000000000..bbcb1167725
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientScreenButton" owl="1">
+ <div class="oe_status" t-on-click="onClick">
+ <span class="message"><t t-esc="message" /></span>
+ <div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange">
+ <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Warning" title="Client Screen Warning"></i>
+ </div>
+ <div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red">
+ <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Disconnected" title="Client Screen Disconnected"></i>
+ </div>
+ <div t-if="state.status === 'success'" class="js_connected oe_icon oe_green">
+ <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Connected" title="Client Screen Connected"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml
new file mode 100644
index 00000000000..6e67512e318
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="DebugWidget" owl="1">
+ <Draggable limitArea="'.pos'">
+ <div class="debug-widget">
+ <header class="drag-handle">
+ <h1>Debug Window</h1>
+ </header>
+ <div class="toggle" t-on-click="trigger('toggle-debug-widget')" title="Dismiss"
+ role="img" aria-label="Dismiss"><i class="fa fa-times" /></div>
+ <div class="content">
+ <p class="category">Electronic Scale</p>
+ <ul>
+ <li>
+ <input t-model="state.weightInput" type="text" class="weight"></input>
+ </li>
+ <li class="button set_weight" t-on-click="setWeight">Set Weight</li>
+ <li class="button reset_weight" t-on-click="resetWeight">Reset</li>
+ </ul>
+
+ <p class="category">Barcode Scanner</p>
+ <ul>
+ <li>
+ <input t-model="state.barcodeInput" type="text" class="ean"></input>
+ </li>
+ <li class="button barcode" t-on-click="barcodeScan">Scan</li>
+ <li class="button custom_ean" t-on-click="barcodeScanEAN">Scan EAN-13</li>
+ </ul>
+
+ <p class="category">Orders</p>
+
+ <ul>
+ <li class="button" t-on-click="deleteOrders">
+ Delete Paid Orders
+ </li>
+ <li class="button" t-on-click="deleteUnpaidOrders">
+ Delete Unpaid Orders
+ </li>
+ <li t-if="!state.isPaidOrdersReady" class="button"
+ t-on-click="preparePaidOrders">
+ Export Paid Orders
+ </li>
+ <a t-else="" t-att-download="paidOrdersFilename" t-att-href="paidOrdersURL"
+ t-on-click="state.isPaidOrdersReady = !state.isPaidOrdersReady">
+ <li class="button">
+ Download Paid Orders
+ </li>
+ </a>
+ <li t-if="!state.isUnpaidOrdersReady" class="button"
+ t-on-click="prepareUnpaidOrders">
+ Export Unpaid Orders
+ </li>
+ <a t-else="" t-att-download="unpaidOrdersFilename"
+ t-att-href="unpaidOrdersURL"
+ t-on-click="state.isUnpaidOrdersReady = !state.isUnpaidOrdersReady">
+ <li class="button">
+ Download Unpaid Orders
+ </li>
+ </a>
+ <li class="button import_orders" style="position:relative">
+ Import Orders
+ <input t-on-change="importOrders" type="file"
+ style="opacity:0;position:absolute;top:0;left:0;right:0;bottom:0;margin:0;cursor:pointer" />
+ </li>
+ </ul>
+
+ <p class="category">Hardware Status</p>
+ <ul>
+ <li class="status weighing">Weighing</li>
+ <li class="button display_refresh" t-on-click="refreshDisplay">
+ Refresh Display
+ </li>
+ </ul>
+ <p class="category">Hardware Events</p>
+ <ul>
+ <li class="event" t-ref="open_cashbox">Open Cashbox</li>
+ <li class="event" t-ref="print_receipt">Print Receipt</li>
+ <li class="event" t-ref="scale_read">Read Weighing Scale</li>
+ </ul>
+ <p class="category">Others</p>
+ <ul>
+ <li class="event">
+ <span>Buffer: </span>
+ <t t-esc="bufferRepr" />
+ </li>
+ </ul>
+ </div>
+ </div>
+ </Draggable>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml
new file mode 100644
index 00000000000..19d9c7c841c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="HeaderButton" owl="1">
+ <div class="header-button close_button" t-att-class="{ confirm: state.label === 'Confirm' }"
+ t-on-click="onClick">
+ <t t-esc="translatedLabel" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml
new file mode 100644
index 00000000000..c557c620107
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderSelector" owl="1">
+ <div class="order-selector">
+ <span class="orders touch-scrollable">
+ <t t-foreach="orderList" t-as="order" t-key="order.cid">
+ <t t-if="order === env.pos.get_order()">
+ <span class="order-button select-order selected">
+ <span class="order-sequence">
+ <t t-esc="order.sequence_number" />
+ </span>
+ <t t-esc="creationTime(order)" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="order-button select-order" t-on-click="selectOrder(order)">
+ <span class="order-sequence">
+ <t t-esc="order.sequence_number" />
+ </span>
+ </span>
+ </t>
+ </t>
+ </span>
+ <span class="order-button square neworder-button" t-on-click="addNewOrder">
+ <i class="fa fa-plus" role="img" aria-label="New order" title="New order" />
+ </span>
+ <span class="order-button square deleteorder-button" t-on-click="deleteCurrentOrder">
+ <i class="fa fa-minus" role="img" aria-label="Delete order" title="Delete order" />
+ </span>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml
new file mode 100644
index 00000000000..f842eec0078
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProxyStatus" owl="1">
+ <div class="oe_status js_proxy" t-on-click="onClick">
+ <span t-if="state.msg" class="js_msg">
+ <t t-esc="state.msg" />
+ </span>
+ <div t-if="state.status === 'connected'" class="js_connected oe_green">
+ <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Connected"
+ title="Proxy Connected"></i>
+ </div>
+ <div t-if="state.status === 'connecting'" class="js_connecting">
+ <i class="fa fa-fw fa-spin fa-spinner" role="img" aria-label="Connecting to Proxy"
+ title="Connecting to Proxy"></i>
+ </div>
+ <div t-if="state.status === 'warning'" class="js_warning oe_orange">
+ <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Warning"
+ title="Proxy Warning"></i>
+ </div>
+ <div t-if="state.status === 'disconnected'" class="js_disconnected oe_red">
+ <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Disconnected"
+ title="Proxy Disconnected"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml
new file mode 100644
index 00000000000..dc5ecc04109
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SaleDetailsButton" owl="1">
+ <div class="oe_status">
+ <div class="js_connected oe_icon">
+ <i class="fa fa-fw fa-print" role="img" aria-label="Print" t-on-click="onClick"
+ title="Print a report with all the sales of the current PoS Session"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml
new file mode 100644
index 00000000000..4a08c7ac769
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SyncNotification" owl="1">
+ <div class="oe_status" t-on-click="onClick">
+ <span t-if="state.msg" class="js_msg">
+ <t t-esc="state.msg" />
+ <span> </span>
+ </span>
+ <div t-if="state.status === 'connected'" class="js_connected oe_icon oe_green">
+ <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Connected"
+ title="Synchronisation Connected"></i>
+ </div>
+ <div t-if="state.status === 'connecting'" class="js_connecting oe_icon">
+ <i class="fa fa-fw fa-spin fa-spinner" role="img"
+ aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i>
+ </div>
+ <div t-if="state.status === 'disconnected'" class="js_disconnected oe_icon oe_red">
+ <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Disconnected"
+ title="Synchronisation Disconnected"></i>
+ </div>
+ <div t-if="state.status === 'error'" class="js_error oe_icon oe_red">
+ <i class="fa fa-fw fa-warning" role="img" aria-label="Synchronisation Error"
+ title="Synchronisation Error"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Misc/Draggable.xml b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml
new file mode 100644
index 00000000000..c0449381d1d
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="Draggable" owl="1">
+ <t t-slot="default"></t>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml
new file mode 100644
index 00000000000..6467e807985
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="NotificationSound" owl="1">
+ <audio t-att-src="props.sound.src" autoplay="true"></audio>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml
new file mode 100644
index 00000000000..f3b22b61785
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ConfirmPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-confirm">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <main class="body">
+ <t t-esc=" props.body" />
+ </main>
+ <footer class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml
new file mode 100644
index 00000000000..b33a51613b8
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="EditListInput" owl="1">
+ <div>
+ <input type="text" t-model="props.item.text" class="popup-input list-line-input"
+ placeholder="Serial/Lot Number" t-on-keyup="onKeyup" />
+ <i class="oe_link_icon fa fa-trash-o" role="img" aria-label="Remove" title="Remove"
+ t-on-click="trigger('remove-item', props.item)"></i>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml
new file mode 100644
index 00000000000..9b6d6354ab4
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="EditListPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-text">
+ <header class="title">
+ <t t-esc="props.title" />
+ </header>
+ <main class="list-lines" t-on-remove-item="removeItem"
+ t-on-create-new-item="createNewItem">
+ <t t-foreach="state.array" t-as="item" t-key="item._id">
+ <EditListInput item="item" />
+ </t>
+ </main>
+ <footer class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ Ok
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ Cancel
+ </div>
+ </footer>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml
new file mode 100644
index 00000000000..6b455fcaa65
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ErrorBarcodePopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-barcode">
+ <header class="title drag-handle">
+ <span>Unknown Barcode</span>
+ <br />
+ <span class="barcode">
+ <t t-esc="props.code" />
+ </span>
+ </header>
+ <main class="body">
+ <t t-esc="translatedMessage" />
+ </main>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="confirm">
+ Ok
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml
new file mode 100644
index 00000000000..0f2e19e35e7
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ErrorPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-error">
+ <p class="title">
+ <t t-esc="props.title" />
+ </p>
+ <p class="body">
+ <t t-esc="props.body" />
+ </p>
+ <div class="footer">
+ <div class="button cancel" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml
new file mode 100644
index 00000000000..e7552c7c919
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ErrorTracebackPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-error">
+ <header class="title">
+ <t t-esc="props.title" />
+ </header>
+ <main class="body traceback">
+ <t t-esc="props.body" />
+ </main>
+ <footer class="footer">
+ <div t-if="!props.exitButtonIsShown" class="button cancel" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div t-if="props.exitButtonIsShown" class="button cancel" t-on-click="trigger(props.exitButtonTrigger)">
+ <t t-esc="props.exitButtonText" />
+ </div>
+ <a t-att-download="tracebackFilename" t-att-href="tracebackUrl">
+ <div class="button icon download">
+ <i class="fa fa-download" role="img"
+ aria-label="Download error traceback"
+ title="Download error traceback"></i>
+ </div>
+ </a>
+ <div class="button icon email" t-on-click="emailTraceback">
+ <i class="fa fa-paper-plane" role="img" aria-label="Send by email"
+ title="Send by email"></i>
+ </div>
+ </footer>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml
new file mode 100644
index 00000000000..50ddd91b59a
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="NumberPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-number" t-att-class="{ 'popup-password': props.isPassword }">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <div class="popup-input value active">
+ <t t-esc="inputBuffer" />
+ </div>
+ <div class="popup-numpad">
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('1')">1</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('2')">2</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('3')">3</button>
+ <t t-if="props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+1')">+1</button>
+ </t>
+ <t t-if="!props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+10')">+10</button>
+ </t>
+ <br />
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('4')">4</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('5')">5</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('6')">6</button>
+ <t t-if="props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+2')">+2</button>
+ </t>
+ <t t-if="!props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+20')">+20</button>
+ </t>
+ <br />
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('7')">7</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('8')">8</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('9')">9</button>
+ <t t-if="props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+5')">+5</button>
+ </t>
+ <t t-if="!props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+50')">+50</button>
+ </t>
+ <br />
+ <button class="input-button numpad-char" t-on-mousedown.prevent="sendInput('Delete')">C</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('0')">0</button>
+ <button class="input-button number-char dot" t-on-mousedown.prevent="sendInput(decimalSeparator)">
+ <t t-esc="decimalSeparator" /></button>
+ <button class="input-button numpad-backspace" t-on-mousedown.prevent="sendInput('Backspace')">
+ <img style="pointer-events: none;"
+ src="/point_of_sale/static/src/img/backspace.png" width="24"
+ height="21" alt="Backspace" />
+ </button>
+ <br />
+ </div>
+ <footer class="footer centered">
+ <div class="button cancel" t-on-mousedown.prevent="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ <div class="button confirm" t-on-mousedown.prevent="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml
new file mode 100644
index 00000000000..9950bc3d28d
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OfflineErrorPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-error">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <main class="body traceback"><t t-esc="props.body"/></main>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="cancel">
+ Ok
+ </div>
+ <div class="button dont-show-again" t-on-click="dontShowAgain">
+ Don't show again
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml
new file mode 100644
index 00000000000..7f5412bafb0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderImportPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-import">
+ <header class="title drag-handle">
+ <span>Finished Importing Orders</span>
+ </header>
+ <ul class="body">
+ <li>Successfully imported <b><t t-esc="props.report.paid or 0" /></b> paid orders</li>
+ <li>Successfully imported <b><t t-esc="props.report.unpaid or 0" /></b> unpaid orders</li>
+ <t t-if="unpaidSkipped">
+ <li><b><t t-esc="unpaidSkipped"/></b> unpaid orders could not be imported
+ <ul>
+ <li><b><t t-esc="props.report.unpaid_skipped_existing or 0" /></b> were duplicates of existing orders</li>
+ <li><b><t t-esc="props.report.unpaid_skipped_session or 0" /></b> belong to another session:
+ <t t-if="props.report.unpaid_skipped_sessions">
+ <ul>
+ <li>Session ids: <b><t t-esc="props.report.unpaid_skipped_sessions" /></b></li>
+ </ul>
+ </t>
+ </li>
+ </ul>
+ </li>
+ </t>
+ </ul>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.confirmText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml
new file mode 100644
index 00000000000..ec250a913f7
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SelectionPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-selection">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <div class="selection scrollable-y touch-scrollable">
+ <t t-foreach="props.list" t-as="item" t-key="item._id">
+ <div class="selection-item" t-att-class="{ selected: item.isSelected }"
+ t-on-click="selectItem(item.id)">
+ <t t-esc="item.label" />
+ </div>
+ </t>
+ </div>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml
new file mode 100644
index 00000000000..a142c995719
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="TextAreaPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-textarea">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <textarea t-model="state.inputValue" t-ref="input"></textarea>
+ <footer class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml
new file mode 100644
index 00000000000..cc28d70638c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="TextInputPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-textinput">
+ <header class="title">
+ <t t-esc="props.title" />
+ </header>
+ <div class="div">
+ <p>
+ <t t-esc="props.body" />
+ </p>
+ <input type="text" t-model="state.inputValue" t-ref="input" />
+ </div>
+ <div class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml
new file mode 100644
index 00000000000..cce9616e974
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SaleDetailsReport" owl="1">
+ <div class="pos-receipt">
+ <t t-if="pos.company_logo_base64">
+ <img class="pos-receipt-logo" t-att-src="pos.company_logo_base64" alt="Logo"/>
+ <br/>
+ </t>
+ <t t-if="!pos.company_logo_base64" class="pos-receipt-center-align">
+ <h1 t-esc="pos.company.name" />
+ <br/>
+ </t>
+ <br /><br />
+
+ <div class="orderlines">
+ <t t-foreach="products" t-as="line" t-key="line.product_id">
+ <div>
+ <t t-esc="line.product_name.substr(0,20)" />
+ <span class="pos-receipt-right-align">
+ <t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])" />
+ <t t-if="line.uom !== 'Units'">
+ <t t-esc="line.uom" />
+ </t>
+ x
+ <t t-esc="pos.format_currency_no_symbol(line.price_unit)" />
+ </span>
+ </div>
+ <t t-if="line.discount !== 0">
+ <div class="pos-receipt-left-padding">Discount: <t t-esc="line.discount" />%</div>
+ </t>
+ </t>
+ </div>
+
+ <br/>
+ <div>------------------------</div>
+ <br/>
+
+ <div>
+ Payments:
+ </div>
+ <div t-foreach="payments" t-as="payment">
+ <t t-esc="payment.name" />
+ <span t-esc="pos.format_currency_no_symbol(payment.total)" class="pos-receipt-right-align"/>
+ </div>
+
+ <br/>
+ <div>------------------------</div>
+ <br/>
+
+ <div>
+ Taxes:
+ </div>
+ <div t-foreach="taxes" t-as="tax">
+ <t t-esc="tax.name" />
+ <span t-esc="pos.format_currency_no_symbol(tax.tax_amount)" class="pos-receipt-right-align"/>
+ </div>
+
+ <br/>
+ <div>------------------------</div>
+ <br/>
+
+ <div>
+ Total:
+ <span t-esc="pos.format_currency_no_symbol(total_paid)" class="pos-receipt-right-align"/>
+ </div>
+
+ <br/>
+ <div class="pos-receipt-order-data">
+ <div><t t-esc="date" /></div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetails.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetails.xml
new file mode 100644
index 00000000000..07bc0decd43
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetails.xml
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientDetails" owl="1">
+ <section class="client-details">
+ <div class="client-picture">
+ <img t-att-src="partnerImageUrl" alt="Partner logo"
+ style="width: 64px; height: 64px; object-fit: cover;" />
+ </div>
+ <div class="client-name">
+ <t t-esc="props.partner.name" />
+ </div>
+ <div class="edit-buttons">
+ <div class="button edit">
+ <i class="fa fa-pencil-square"
+ t-on-click="trigger('activate-edit-mode', {isNewClient: false})" role="img"
+ aria-label="Edit" title="Edit" />
+ </div>
+ </div>
+ <div class="client-details-box clearfix">
+ <div class="client-details-left">
+ <div class="client-detail">
+ <span class="label">Address</span>
+ <t t-if="props.partner.address">
+ <span class="detail client-address">
+ <t t-esc="props.partner.address" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="detail client-address empty">N/A</span>
+ </t>
+ </div>
+ <div class="client-detail">
+ <span class="label">Email</span>
+ <t t-if="props.partner.email">
+ <span class="detail client-email">
+ <t t-esc="props.partner.email" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="detail client-email empty">N/A</span>
+ </t>
+ </div>
+ <div class="client-detail">
+ <span class="label">Phone</span>
+ <t t-if="props.partner.phone">
+ <span class="detail client-phone">
+ <t t-esc="props.partner.phone" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="detail client-phone empty">N/A</span>
+ </t>
+ </div>
+ </div>
+ <div class="client-details-right">
+ <div class="client-detail">
+ <span class="label">Barcode</span>
+ <t t-if="props.partner.barcode">
+ <span class="detail client-id">
+ <t t-esc="props.partner.barcode" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="detail client-id empty">N/A</span>
+ </t>
+ </div>
+ <div class="client-detail">
+ <span class="label">Tax ID</span>
+ <t t-if="props.partner.vat">
+ <span class="detail vat">
+ <t t-esc="props.partner.vat" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="detail vat empty">N/A</span>
+ </t>
+ </div>
+ <div t-if="env.pos.pricelists.length lte 1" class="client-detail">
+ <span class='label'>Pricelist</span>
+ <t t-if="props.partner.property_product_pricelist">
+ <span class="detail property_product_pricelist">
+ <t t-esc="props.partner.property_product_pricelist[1]" />
+ </span>
+ </t>
+ <t t-else="">
+ <span class="detail property_product_pricelist empty">N/A</span>
+ </t>
+ </div>
+ </div>
+ </div>
+ </section>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml
new file mode 100644
index 00000000000..870305d6af0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml
@@ -0,0 +1,129 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientDetailsEdit" owl="1">
+ <section class="client-details edit">
+ <div class="client-picture">
+ <t t-if="partnerImageUrl">
+ <img t-att-src="partnerImageUrl" alt="Partner"
+ style="width: 64px; height: 64px; object-fit: cover;" />
+ </t>
+ <t t-else="">
+ <i class="fa fa-camera" role="img" aria-label="Picture" title="Picture"></i>
+ </t>
+ <input type="file" class="image-uploader" t-on-change="uploadImage" />
+ </div>
+ <input class="detail client-name" name="name" t-att-value="props.partner.name"
+ placeholder="Name" t-on-change="captureChange" />
+ <div class="edit-buttons">
+ <div class="button undo">
+ <i class="fa fa-undo" t-on-click="trigger('cancel-edit')" role="img"
+ aria-label="Undo" title="Undo" />
+ </div>
+ <div class="button save">
+ <i class="fa fa-floppy-o" t-on-click="saveChanges" role="img" aria-label="Save"
+ title="Save" />
+ </div>
+ </div>
+ <div class="client-details-box clearfix">
+ <div class="client-details-left">
+ <div class="client-detail">
+ <span class="label">Street</span>
+ <input class="detail client-address-street" name="street"
+ t-on-change="captureChange" t-att-value="props.partner.street || ''"
+ placeholder="Street" />
+ </div>
+ <div class="client-detail">
+ <span class="label">City</span>
+ <input class="detail client-address-city" name="city"
+ t-on-change="captureChange" t-att-value="props.partner.city || ''"
+ placeholder="City" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Postcode</span>
+ <input class="detail client-address-zip" name="zip"
+ t-on-change="captureChange" t-att-value="props.partner.zip || ''"
+ placeholder="ZIP" />
+ </div>
+ <div class="client-detail">
+ <span class="label">State</span>
+ <select class="detail client-address-states needsclick" name="state_id"
+ t-on-change="captureChange">
+ <option value="">None</option>
+ <t t-foreach="env.pos.states" t-as="state" t-key="state.id">
+ <option t-if="props.partner.country_id[0] == state.country_id[0]"
+ t-att-value="state.id"
+ t-att-selected="props.partner.state_id ? ((state.id === props.partner.state_id[0]) ? true : undefined) : undefined">
+ <t t-esc="state.name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ <div class="client-detail">
+ <span class="label">Country</span>
+ <select class="detail client-address-country needsclick" name="country_id"
+ t-on-change="captureChange">
+ <option value="">None</option>
+ <t t-foreach="env.pos.countries" t-as="country" t-key="country.id">
+ <option t-att-value="country.id"
+ t-att-selected="props.partner.country_id ? ((country.id === props.partner.country_id[0]) ? true : undefined) : undefined">
+ <t t-esc="country.name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ </div>
+ <div class="client-details-right">
+ <div class="client-detail">
+ <span class="label">Language</span>
+ <select class="detail client-lang needsclick" name="lang"
+ t-on-change="captureChange">
+ <t t-foreach="env.pos.langs" t-as="lang" t-key="lang.id">
+ <option t-att-value="lang.code"
+ t-att-selected="props.partner.lang ? ((lang.code === props.partner.lang) ? true : undefined) : lang.code === env.pos.user.lang? true : undefined">
+ <t t-esc="lang.name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ <div class="client-detail">
+ <span class="label">Email</span>
+ <input class="detail client-email" name="email" type="email"
+ t-on-change="captureChange"
+ t-att-value="props.partner.email || ''" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Phone</span>
+ <input class="detail client-phone" name="phone" type="tel"
+ t-on-change="captureChange"
+ t-att-value="props.partner.phone || ''" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Barcode</span>
+ <input class="detail barcode" name="barcode" t-on-change="captureChange"
+ t-att-value="props.partner.barcode || ''" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Tax ID</span>
+ <input class="detail vat" name="vat" t-on-change="captureChange"
+ t-att-value="props.partner.vat || ''" />
+ </div>
+ <div t-if="env.pos.pricelists.length gt 1" class="client-detail">
+ <span class="label">Pricelist</span>
+ <select class="detail needsclick" name="property_product_pricelist"
+ t-on-change="captureChange">
+ <t t-foreach="env.pos.pricelists" t-as="pricelist"
+ t-key="pricelist.id">
+ <option t-att-value="pricelist.id"
+ t-att-selected="props.partner.property_product_pricelist ? (pricelist.id === props.partner.property_product_pricelist[0] ? true : false) : false">
+ <t t-esc="pricelist.display_name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ </div>
+ </div>
+ </section>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml
new file mode 100644
index 00000000000..c5aecab131a
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientLine" owl="1">
+ <tr t-attf-class="client-line {{highlight}}" t-att-data-id="props.partner.id"
+ t-on-click="trigger('click-client', {client: props.partner})">
+ <td>
+ <t t-esc="props.partner.name" />
+ </td>
+ <td>
+ <t t-esc="props.partner.address" />
+ </td>
+ <td>
+ <t t-esc="props.partner.phone or props.partner.mobile or ''" />
+ </td>
+ </tr>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml
new file mode 100644
index 00000000000..a83c4db173b
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientListScreen" owl="1">
+ <div class="clientlist-screen screen" t-on-activate-edit-mode="activateEditMode">
+ <div class="screen-content">
+ <section class="top-content">
+ <span class="button back" t-on-click="back">
+ <i class="fa fa-angle-double-left"></i>
+ Cancel
+ </span>
+ <span class="searchbox">
+ <input placeholder="Search Customers" t-on-keyup="updateClientList" />
+ <span class="search-clear"></span>
+ </span>
+ <span class="searchbox"></span>
+ <span class="button new-customer" role="img" aria-label="Add a customer"
+ t-on-click="trigger('activate-edit-mode', { isNewClient: true })"
+ title="Add a customer">
+ <i class="fa fa-user"></i>
+ <i class="fa fa-plus"></i>
+ </span>
+ <span t-if="isNextButtonVisible" t-on-click="clickNext"
+ class="button next highlight">
+ <t t-esc="nextButton.text" />
+ </span>
+ </section>
+ <section class="full-content">
+ <div class="client-window">
+ <section class="subwindow collapsed">
+ <div class="subwindow-container collapsed">
+ <div t-if="state.detailIsShown" class="client-details-contents subwindow-container-fix">
+ <t t-if="state.isEditMode">
+ <ClientDetailsEdit t-props="state.editModeProps"
+ t-on-cancel-edit="cancelEdit"
+ t-on-save-changes="saveChanges" />
+ </t>
+ <t t-else="">
+ <ClientDetails partner="state.selectedClient" />
+ </t>
+ </div>
+ </div>
+ </section>
+ <section class="subwindow list">
+ <div class="subwindow-container">
+ <div class="subwindow-container-fix touch-scrollable scrollable-y">
+ <table class="client-list">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Address</th>
+ <th>Phone</th>
+ </tr>
+ </thead>
+ <tbody class="client-list-contents">
+ <t t-foreach="clients" t-as="partner"
+ t-key="partner.id">
+ <ClientLine partner="partner"
+ selectedClient="state.selectedClient"
+ detailIsShown="state.detailIsShown"
+ t-on-click-client="clickClient" />
+ </t>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </section>
+ </div>
+ </section>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml
new file mode 100644
index 00000000000..381cd88c61d
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PSNumpadInputButton" owl="1">
+ <button t-attf-class="{{ _class }}"
+ t-on-click="trigger('input-from-numpad', { key: props.value })">
+ <t t-slot="default">
+ <t t-if="props.text">
+ <t t-esc="props.text" />
+ </t>
+ <t t-else="">
+ <t t-esc="props.value" />
+ </t>
+ </t>
+ </button>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml
new file mode 100644
index 00000000000..373a7578f3f
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentMethodButton" owl="1">
+ <div class="button paymentmethod"
+ t-on-click="trigger('new-payment-line', props.paymentMethod)">
+ <t t-esc="props.paymentMethod.name" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml
new file mode 100644
index 00000000000..6d3e0f0f9cf
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreen" owl="1">
+ <div class="payment-screen screen" t-att-class="{ oe_hidden: !props.isShown }">
+ <div class="screen-content">
+ <div class="top-content">
+ <span class="button back"
+ t-on-click="showScreen('ProductScreen')">
+ <i class="fa fa-angle-double-left"></i>
+ Back
+ </span>
+ <h1>Payment</h1>
+ <span class="button next" t-att-class="{ highlight: currentOrder.is_paid() }"
+ t-on-click="validateOrder(false)">
+ Validate
+ <i class="fa fa-angle-double-right"></i>
+ </span>
+ </div>
+ <div class="left-content pc40 touch-scrollable scrollable-y">
+ <div class="paymentmethods-container">
+ <div class="paymentmethods">
+ <t t-foreach="env.pos.payment_methods" t-as="paymentMethod"
+ t-key="paymentMethod.id">
+ <PaymentMethodButton paymentMethod="paymentMethod" />
+ </t>
+ </div>
+ </div>
+ </div>
+ <div class="right-content pc60 touch-scrollable scrollable-y">
+
+ <section class="paymentlines-container">
+ <PaymentScreenPaymentLines paymentLines="paymentLines" />
+ </section>
+
+ <section class="payment-numpad">
+ <PaymentScreenNumpad />
+ </section>
+
+ <div class="payment-buttons">
+ <div class="button" t-on-click="selectClient">
+ <i class="fa fa-user" role="img" aria-label="Customer"
+ title="Customer" />
+ <span class="js_customer_name">
+ <t t-if="env.pos.get_client()">
+ <t t-esc="env.pos.get_client().name" />
+ </t>
+ <t t-if="!env.pos.get_client()">
+ Customer
+ </t>
+ </span>
+ </div>
+ <div t-if="env.pos.config.module_account" class="button js_invoice"
+ t-att-class="{ highlight: currentOrder.is_to_invoice() }"
+ t-on-click="toggleIsToInvoice">
+ <i class="fa fa-file-text-o" /> Invoice
+ </div>
+ <div class="button js_email"
+ t-att-class="{ highlight: currentOrder.is_to_email() }"
+ t-on-click="toggleIsToEmail">
+ <i class="fa fa-inbox" /> Email
+ </div>
+ <div t-if="env.pos.config.tip_product_id" class="button js_tip"
+ t-on-click="addTip">
+ <i class="fa fa-heart" /> Tip
+ </div>
+ <div t-if="env.pos.config.iface_cashdrawer" class="button js_cashdrawer"
+ t-on-click="openCashbox">
+ <i class="fa fa-archive" /> Open Cashbox
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml
new file mode 100644
index 00000000000..1576f731007
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreenElectronicPayment" owl="1">
+ <div class="paymentline electronic_payment">
+ <t t-if="props.line.payment_status == 'pending'">
+ <div>
+ Payment request pending
+ </div>
+ <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)">
+ Send
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'retry'">
+ <div>
+ Transaction cancelled
+ </div>
+ <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)">
+ Retry
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'force_done'">
+ <div>
+ Connection error
+ </div>
+ <div class="button send_force_done" title="Force Done" t-on-click="trigger('send-force-done', props.line)">
+ Force done
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'waitingCard'">
+ <div>
+ Waiting for card
+ </div>
+ <div class="button send_payment_cancel" title="Cancel Payment Request" t-on-click="trigger('send-payment-cancel', props.line)">
+ Cancel
+ </div>
+ </t>
+ <t t-elif="['waiting', 'waitingCancel'].includes(props.line.payment_status)">
+ <div>
+ Request sent
+ </div>
+ <div>
+ <i class="fa fa-spinner fa-spin" role="img" />
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'reversing'">
+ <div>
+ Reversal request sent to terminal
+ </div>
+ <div>
+ <i class="fa fa-spinner fa-spin" role="img" />
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'done'">
+ <t t-if="props.line.can_be_reversed">
+ <div>
+ Payment Successful
+ </div>
+ <div class="button send_payment_reversal" title="Reverse Payment" t-on-click="trigger('send-payment-reverse', props.line)">
+ Reverse
+ </div>
+ </t>
+ <t t-else="">
+ <div>
+ Payment Successful
+ </div>
+ <div></div>
+ </t>
+ </t>
+ <t t-elif="props.line.payment_status == 'reversed'">
+ <div class="paymentline electronic_payment">
+ <div>
+ Payment reversed
+ </div>
+ <div></div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml
new file mode 100644
index 00000000000..d988ab5f600
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreenNumpad" owl="1">
+ <div class="numpad">
+ <PSNumpadInputButton value="'1'" />
+ <PSNumpadInputButton value="'2'" />
+ <PSNumpadInputButton value="'3'" />
+ <PSNumpadInputButton value="'+10'" changeClassTo="'mode-button'" />
+ <br />
+ <PSNumpadInputButton value="'4'" />
+ <PSNumpadInputButton value="'5'" />
+ <PSNumpadInputButton value="'6'" />
+ <PSNumpadInputButton value="'+20'" changeClassTo="'mode-button'" />
+ <br />
+ <PSNumpadInputButton value="'7'" />
+ <PSNumpadInputButton value="'8'" />
+ <PSNumpadInputButton value="'9'" />
+ <PSNumpadInputButton value="'+50'" changeClassTo="'mode-button'" />
+ <br />
+ <PSNumpadInputButton value="'-'" text="'+/-'" />
+ <PSNumpadInputButton value="'0'" />
+ <PSNumpadInputButton value="decimalPoint" />
+ <PSNumpadInputButton value="'Backspace'">
+ <img src="/point_of_sale/static/src/img/backspace.png" width="24" height="21"
+ alt="Backspace" />
+ </PSNumpadInputButton>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml
new file mode 100644
index 00000000000..14aca829264
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml
@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreenPaymentLines" owl="1">
+ <div t-if="props.paymentLines.length === 0" class="paymentlines-empty">
+ <div class="total">
+ <t t-esc="totalDueText" />
+ </div>
+ <div class="message">
+ Please select a payment method.
+ </div>
+ </div>
+
+ <div t-else="">
+ <div class="payment-status-container">
+ <div>
+ <div class="payment-status-remaining">
+ <span class="label">Remaining</span>
+ <span class="amount"
+ t-att-class="{ highlight: currentOrder.get_due() > 0 }">
+ <t t-esc="remainingText" />
+ </span>
+ </div>
+ <div class="payment-status-total-due">
+ <span class="label">Total Due</span>
+ <span>
+ <t t-esc="totalDueText" />
+ </span>
+ </div>
+ </div>
+ <div>
+ <div class="payment-status-change">
+ <span class="label">Change</span>
+ <span class="amount"
+ t-att-class="{ highlight: currentOrder.get_change() > 0 }">
+ <t t-esc="changeText" />
+ </span>
+ </div>
+ </div>
+ </div>
+ <hr />
+ <div class="paymentlines">
+ <div class="paymentline heading">
+ <div>Tendered</div>
+ <div>Method</div>
+ <div></div>
+ </div>
+ <div class="body">
+ <t t-foreach="props.paymentLines" t-as="line" t-key="line.cid">
+ <t t-if="line.selected">
+ <div class="paymentline selected"
+ t-att-class="selectedLineClass(line)"
+ t-on-click="trigger('select-payment-line', { cid: line.cid })">
+ <t
+ t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)">
+ <div class="col-tendered">
+ <t
+ t-esc="env.pos.format_currency_no_symbol(line.get_amount())" />
+ </div>
+ </t>
+ <t t-else="">
+ <div class="col-tendered edit">
+ <t t-esc="formatLineAmount(line)" />
+ </div>
+ </t>
+ <div class="col-name">
+ <t t-esc="line.payment_method.name" />
+ </div>
+ <t
+ t-if="line and line.payment_status and ['done', 'reversed', 'reversing'].includes(line.payment_status)">
+ <div />
+ </t>
+ <t t-else="">
+ <div class="delete-button"
+ t-on-click="trigger('delete-payment-line', { cid: line.cid })"
+ aria-label="Delete" title="Delete">
+ <i class="fa fa-times-circle" />
+ </div>
+ </t>
+ </div>
+ <t t-if="line and line.payment_status">
+ <PaymentScreenElectronicPayment line="line" />
+ </t>
+ </t>
+ <t t-else="">
+ <div class="paymentline"
+ t-att-class="unselectedLineClass(line)"
+ t-on-click="trigger('select-payment-line', { cid: line.cid })">
+ <div class="col-tendered">
+ <t
+ t-esc="env.pos.format_currency_no_symbol(line.get_amount())" />
+ </div>
+ <div class="col-name">
+ <t t-esc="line.payment_method.name" />
+ </div>
+ <t
+ t-if="line and line.payment_status and ['done', 'reversed', 'reversing'].includes(line.payment_status)">
+ <div />
+ </t>
+ <t t-else="">
+ <div class="delete-button"
+ t-on-click="trigger('delete-payment-line', { cid: line.cid })"
+ aria-label="Delete" title="Delete">
+ <i class="fa fa-times-circle" />
+ </div>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml
new file mode 100644
index 00000000000..7a04319af1e
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ActionpadWidget" owl="1">
+ <div class="actionpad">
+ <button class="button set-customer" t-att-class="{'decentered': isLongName}"
+ t-on-click="trigger('click-customer')">
+ <i class="fa fa-user" role="img" aria-label="Customer" title="Customer" />
+ <t t-if="client">
+ <t t-esc="client.name" />
+ </t>
+ <t t-else="">
+ Customer
+ </t>
+ </button>
+ <button class="button pay" t-on-click="trigger('click-pay')">
+ <div class="pay-circle">
+ <i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" />
+ </div>
+ Payment
+ </button>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml
new file mode 100644
index 00000000000..0e9ba155b9b
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CategoryBreadcrumb" owl="1">
+ <span class="breadcrumb">
+ <img src="/point_of_sale/static/src/img/bc-arrow-big.png" class="breadcrumb-arrow"
+ alt="Slash" />
+ <span class="breadcrumb-button"
+ t-on-click="trigger('switch-category', props.category.id)">
+ <t t-esc="props.category.name"></t>
+ </span>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml
new file mode 100644
index 00000000000..da829cba01a
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CategoryButton" owl="1">
+ <span class="category-button" t-on-click="trigger('switch-category', props.category.id)">
+ <div class="category-img">
+ <img t-att-src="imageUrl" alt="Category" />
+ </div>
+ <div class="category-name">
+ <t t-esc="props.category.name" />
+ </div>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml
new file mode 100644
index 00000000000..de052e16789
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CategorySimpleButton" owl="1">
+ <span class="category-simple-button"
+ t-on-click="trigger('switch-category', props.category.id)">
+ <t t-esc="props.category.name" />
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml
new file mode 100644
index 00000000000..ab09f363c20
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SetFiscalPositionButton" owl="1">
+ <div class="control-button o_fiscal_position_button">
+ <i class="fa fa-book" role="img" aria-label="Set fiscal position"
+ title="Set fiscal position" />
+ <t t-esc='currentFiscalPositionName' />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml
new file mode 100644
index 00000000000..ffe3e3ecae3
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SetPricelistButton" owl="1">
+ <div class="control-button o_pricelist_button">
+ <i class="fa fa-th-list" role="img" aria-label="Price list" title="Price list" />
+ <t t-esc="currentPricelistName" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml
new file mode 100644
index 00000000000..2fa2ac159bf
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="HomeCategoryBreadcrumb" owl="1">
+ <span class="breadcrumb">
+ <span class="breadcrumb-button breadcrumb-home"
+ t-on-click="trigger('switch-category', 0)">
+ <i class="fa fa-home" role="img" aria-label="Home" title="Home"></i>
+ </span>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml
new file mode 100644
index 00000000000..e1affd63dee
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="NumpadWidget" owl="1">
+ <div class="numpad">
+ <button class="input-button number-char" t-on-click="sendInput('1')">1</button>
+ <button class="input-button number-char" t-on-click="sendInput('2')">2</button>
+ <button class="input-button number-char" t-on-click="sendInput('3')">3</button>
+ <button class="mode-button" t-att-class="{'selected-mode': state.mode === 'quantity'}"
+ t-on-click="changeMode('quantity')">Qty</button>
+ <br />
+ <button class="input-button number-char" t-on-click="sendInput('4')">4</button>
+ <button class="input-button number-char" t-on-click="sendInput('5')">5</button>
+ <button class="input-button number-char" t-on-click="sendInput('6')">6</button>
+ <button class="mode-button" t-att-class="{'selected-mode': state.mode === 'discount'}"
+ t-on-click="changeMode('discount')">Disc</button>
+ <br />
+ <button class="input-button number-char" t-on-click="sendInput('7')">7</button>
+ <button class="input-button number-char" t-on-click="sendInput('8')">8</button>
+ <button class="input-button number-char" t-on-click="sendInput('9')">9</button>
+ <button class="mode-button" t-att-class="{
+ 'selected-mode': state.mode === 'price',
+ 'disabled-mode': !hasPriceControlRights
+ }" t-att-disabled="!hasPriceControlRights"
+ t-on-click="changeMode('price')">Price</button>
+ <br />
+ <button class="input-button numpad-minus" t-on-click="sendInput('-')">+/-</button>
+ <button class="input-button number-char" t-on-click="sendInput('0')">0</button>
+ <button class="input-button number-char" t-on-click="sendInput('.')">.</button>
+ <button class="input-button numpad-backspace" t-on-click="sendInput('Backspace')">
+ <img style="pointer-events: none;" src="/point_of_sale/static/src/img/backspace.png"
+ width="24" height="21" alt="Backspace" />
+ </button>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml
new file mode 100644
index 00000000000..04e1ab32446
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderSummary" owl="1">
+ <div class="summary clearfix">
+ <div class="line">
+ <div class="entry total">
+ <span class="badge">Total: </span>
+ <span class="value">
+ <t t-esc="props.total" />
+ </span>
+ <div class="subentry">
+ Taxes:
+ <span class="value">
+ <t t-esc="props.tax" />
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml
new file mode 100644
index 00000000000..792291d2cb7
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderWidget" owl="1">
+ <div class="order-container">
+ <div t-ref="scrollable" class="order-scroller touch-scrollable">
+ <div class="order">
+ <t t-if="orderlinesArray.length === 0">
+ <div class="order-empty">
+ <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
+ title="Shopping cart" />
+ <h1>Your shopping cart is empty</h1>
+ </div>
+ </t>
+ <t t-else="">
+ <ul class="orderlines">
+ <t t-foreach="orderlinesArray" t-as="orderline" t-key="orderline.id">
+ <Orderline line="orderline" />
+ </t>
+ </ul>
+ <OrderSummary total="state.total" tax="state.tax" />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
new file mode 100644
index 00000000000..bc36d244f4c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="Orderline" owl="1">
+ <li t-on-click="selectLine" class="orderline" t-att-class="addedClasses">
+ <span class="product-name">
+ <t t-esc="props.line.get_product().display_name"/>
+ <span> </span>
+ <t t-if="props.line.get_product().tracking!=='none'">
+ <t t-if="props.line.has_valid_product_lot()">
+ <i t-on-click.stop="lotIconClicked"
+ class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_green"
+ aria-label="Valid product lot"
+ role="img"
+ title="Valid product lot"
+ />
+ </t>
+ <t t-else="">
+ <i t-on-click.stop="lotIconClicked"
+ class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_red"
+ aria-label="Invalid product lot"
+ role="img"
+ title="Invalid product lot"
+ />
+ </t>
+ </t>
+ </span>
+ <span class="price">
+ <t t-esc="env.pos.format_currency(props.line.get_display_price())"/>
+ </span>
+ <ul class="info-list">
+ <t t-if="props.line.get_quantity_str() !== '1' || props.line.selected ">
+ <li class="info">
+ <em>
+ <t t-esc="props.line.get_quantity_str()" />
+ </em>
+ <t t-esc="props.line.get_unit().name" />
+ at
+ <t t-if="props.line.display_discount_policy() == 'without_discount' and
+ props.line.get_unit_display_price() != props.line.get_lst_price()">
+ <s>
+ <t t-esc="env.pos.format_currency(props.line.get_fixed_lst_price(),'Product Price')" />
+ </s>
+ <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" />
+ </t>
+ <t t-else="">
+ <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" />
+ </t>
+ /
+ <t t-esc="props.line.get_unit().name" />
+ </li>
+ </t>
+ <t t-if="props.line.get_discount_str() !== '0'">
+ <li class="info">
+ With a
+ <em>
+ <t t-esc="props.line.get_discount_str()" />%
+ </em>
+ discount
+ </li>
+ </t>
+ </ul>
+ </li>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml
new file mode 100644
index 00000000000..4825efafdcb
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductItem" owl="1">
+ <article class="product" tabindex="0" t-on-keypress="spaceClickProduct"
+ t-on-click="trigger('click-product', props.product)"
+ t-att-data-product-id="props.product.id"
+ t-attf-aria-labelledby="article_product_{{props.product.id}}">
+ <div class="product-img">
+ <img t-att-src="imageUrl" t-att-alt="props.product.display_name" />
+ <span class="price-tag">
+ <t t-esc="price" />
+ </span>
+ </div>
+ <div class="product-name" t-attf-id="article_product_{{props.product.id}}">
+ <t t-esc="props.product.display_name" />
+ </div>
+ </article>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml
new file mode 100644
index 00000000000..715da883bb6
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductList" owl="1">
+ <div class="product-list-container">
+ <div class="product-list-scroller touch-scrollable">
+ <div t-if="props.products.length != 0" class="product-list">
+ <t t-foreach="props.products" t-as="product" t-key="product.id">
+ <ProductItem product="product" />
+ </t>
+ </div>
+ <div t-else="" class="product-list-empty">
+ <div class="product-list-empty">
+ <t t-if="props.searchWord !== ''">
+ <p>
+ No results found for "
+ <b t-esc="props.searchWord"></b>
+ ".
+ </p>
+ </t>
+ <t t-else="">
+ <p>There are no products in this category.</p>
+ </t>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml
new file mode 100644
index 00000000000..7d3a6ad3499
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductScreen" owl="1">
+ <div class="product-screen screen" t-att-class="{ oe_hidden: !props.isShown }">
+ <div class="leftpane">
+ <div class="window">
+ <div class="subwindow">
+ <div class="subwindow-container">
+ <div class="subwindow-container-fix">
+ <OrderWidget />
+ </div>
+ </div>
+ </div>
+
+ <div class="subwindow collapsed">
+ <div class="subwindow-container">
+ <div class="subwindow-container-fix pads">
+ <div class="control-buttons">
+ <t t-foreach="controlButtons" t-as="cb" t-key="cb.name">
+ <t t-component="cb.component" t-key="cb.name" />
+ </t>
+ </div>
+ <ActionpadWidget client="client" />
+ <NumpadWidget />
+ </div>
+ </div>
+ </div>
+
+ </div>
+ </div>
+ <div class="rightpane">
+ <ProductsWidget />
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml
new file mode 100644
index 00000000000..aca096590f0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductsWidget" owl="1">
+ <table class="layout-table">
+ <tbody>
+ <tr class="header-row">
+ <td class="header-cell">
+ <ProductsWidgetControlPanel breadcrumbs="breadcrumbs"
+ subcategories="subcategories" />
+ </td>
+ </tr>
+
+ <tr class="content-row">
+ <td class="content-cell">
+ <div class="content-container">
+ <ProductList products="productsToDisplay" searchWord="searchWord" />
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml
new file mode 100644
index 00000000000..92db04835b1
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductsWidgetControlPanel" owl="1">
+ <div>
+ <header class="rightpane-header">
+ <!-- Breadcrumbs -->
+ <div class="breadcrumbs">
+ <HomeCategoryBreadcrumb />
+ <t t-foreach="props.breadcrumbs" t-as="category" t-key="category.id">
+ <CategoryBreadcrumb category="category" />
+ </t>
+ </div>
+ <!-- Searchbox -->
+ <div class="searchbox">
+ <input t-ref="search-word-input" placeholder="Search Products"
+ t-on-keyup="updateSearch" />
+ <span class="search-clear left">
+ <i class="fa fa-search" />
+ </span>
+ <span class="search-clear right" t-on-click="clearSearch">
+ <i class="fa fa-remove" />
+ </span>
+ </div>
+ </header>
+ <!-- Subcategories -->
+ <t t-if="props.subcategories.length > 0">
+ <div class="categories">
+ <div class="category-list-scroller touch-scrollable">
+ <div class="category-list"
+ t-att-class="{simple: !env.pos.config.iface_display_categ_images}">
+ <t t-foreach="props.subcategories" t-as="category" t-key="category.id">
+ <t t-if="env.pos.config.iface_display_categ_images">
+ <CategoryButton category="category" />
+ </t>
+ <t t-else="">
+ <CategorySimpleButton category="category" />
+ </t>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml
new file mode 100644
index 00000000000..ea29ec1a02e
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml
@@ -0,0 +1,196 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderReceipt" owl="1">
+ <div class="pos-receipt">
+ <t t-if="receipt.company.logo">
+ <img class="pos-receipt-logo" t-att-src="receipt.company.logo" alt="Logo"/>
+ <br/>
+ </t>
+ <t t-if="!receipt.company.logo">
+ <h2 class="pos-receipt-center-align">
+ <t t-esc="receipt.company.name" />
+ </h2>
+ <br/>
+ </t>
+ <div class="pos-receipt-contact">
+ <t t-if="receipt.company.contact_address">
+ <div><t t-esc="receipt.company.contact_address" /></div>
+ </t>
+ <t t-if="receipt.company.phone">
+ <div>Tel:<t t-esc="receipt.company.phone" /></div>
+ </t>
+ <t t-if="receipt.company.vat">
+ <div>VAT:<t t-esc="receipt.company.vat" /></div>
+ </t>
+ <t t-if="receipt.company.email">
+ <div><t t-esc="receipt.company.email" /></div>
+ </t>
+ <t t-if="receipt.company.website">
+ <div><t t-esc="receipt.company.website" /></div>
+ </t>
+ <t t-if="receipt.header_html">
+ <t t-raw="receipt.header_html" />
+ </t>
+ <t t-if="!receipt.header_html and receipt.header">
+ <div><t t-esc="receipt.header" /></div>
+ </t>
+ <t t-if="receipt.cashier">
+ <div class="cashier">
+ <div>--------------------------------</div>
+ <div>Served by <t t-esc="receipt.cashier" /></div>
+ </div>
+ </t>
+ </div>
+ <br /><br />
+
+ <!-- Orderlines -->
+
+ <div class="orderlines">
+ <t t-foreach="receipt.orderlines" t-as="line" t-key="line.id">
+ <t t-if="isSimple(line)">
+ <div>
+ <t t-esc="line.product_name_wrapped[0]" />
+ <span t-esc="env.pos.format_currency_no_symbol(line.price_display)" class="price_display pos-receipt-right-align"/>
+ </div>
+ <WrappedProductNameLines line="line" />
+ </t>
+ <t t-else="">
+ <div t-esc="line.product_name_wrapped[0]" />
+ <WrappedProductNameLines line="line" />
+ <t t-if="line.display_discount_policy == 'without_discount' and line.price != line.price_lst">
+ <div class="pos-receipt-left-padding">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_lst)" />
+ ->
+ <t t-esc="env.pos.format_currency_no_symbol(line.price)" />
+ </div>
+ </t>
+ <t t-elif="line.discount !== 0">
+ <div class="pos-receipt-left-padding">
+ <t t-if="env.pos.config.iface_tax_included === 'total'">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_with_tax_before_discount)"/>
+ </t>
+ <t t-else="">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price)"/>
+ </t>
+ </div>
+ </t>
+ <t t-if="line.discount !== 0">
+ <div class="pos-receipt-left-padding">
+ Discount: <t t-esc="line.discount" />%
+ </div>
+ </t>
+ <div class="pos-receipt-left-padding">
+ <t t-esc="Math.round(line.quantity * Math.pow(10, env.pos.dp['Product Unit of Measure'])) / Math.pow(10, env.pos.dp['Product Unit of Measure'])"/>
+ <t t-if="line.unit_name !== 'Units'" t-esc="line.unit_name" />
+ x
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_display_one)" />
+ <span class="price_display pos-receipt-right-align">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_display)" />
+ </span>
+ </div>
+ </t>
+ </t>
+ </div>
+
+ <!-- Subtotal -->
+
+ <t t-if="!isTaxIncluded">
+ <div class="pos-receipt-right-align">--------</div>
+ <br/>
+ <div>Subtotal<span t-esc="env.pos.format_currency(receipt.subtotal)" class="pos-receipt-right-align"/></div>
+ <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name">
+ <div>
+ <t t-esc="tax.name" />
+ <span t-esc='env.pos.format_currency_no_symbol(tax.amount)' class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ </t>
+
+ <!-- Total -->
+ <div class="pos-receipt-right-align">--------</div>
+ <br/>
+ <div class="pos-receipt-amount">
+ TOTAL
+ <span t-esc="env.pos.format_currency(receipt.total_with_tax)" class="pos-receipt-right-align"/>
+ </div>
+ <t t-if="receipt.total_rounded != receipt.total_with_tax">
+ <div class="pos-receipt-amount">
+ ROUNDED
+ <span t-esc="env.pos.format_currency(receipt.total_rounded)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <br/><br/>
+
+ <!-- Payment Lines -->
+
+ <t t-foreach="paymentlines" t-as="line" t-key="line.cid">
+ <div>
+ <t t-esc="line.name" />
+ <span t-esc="env.pos.format_currency_no_symbol(line.get_amount())" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <br/>
+
+ <div class="pos-receipt-amount receipt-change">
+ CHANGE
+ <span t-esc="env.pos.format_currency(receipt.change)" class="pos-receipt-right-align"/>
+ </div>
+ <br/>
+
+ <!-- Extra Payment Info -->
+
+ <t t-if="receipt.total_discount">
+ <div>
+ Discounts
+ <span t-esc="env.pos.format_currency(receipt.total_discount)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <t t-if="isTaxIncluded">
+ <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name">
+ <div>
+ <t t-esc="tax.name" />
+ <span t-esc="env.pos.format_currency_no_symbol(tax.amount)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <div>
+ Total Taxes
+ <span t-esc="env.pos.format_currency(receipt.total_tax)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+
+ <div class="before-footer" />
+
+ <!-- Footer -->
+ <div t-if="receipt.footer_html" class="pos-receipt-center-align">
+ <t t-raw="receipt.footer_html" />
+ </div>
+
+ <div t-if="!receipt.footer_html and receipt.footer" class="pos-receipt-center-align">
+ <br/>
+ <t t-esc="receipt.footer" />
+ <br/>
+ <br/>
+ </div>
+
+ <div class="after-footer">
+ <t t-foreach="paymentlines" t-as="line">
+ <t t-if="line.ticket">
+ <br />
+ <div class="pos-payment-terminal-receipt">
+ <t t-raw="line.ticket" />
+ </div>
+ </t>
+ </t>
+ </div>
+
+ <br/>
+ <div class="pos-receipt-order-data">
+ <div><t t-esc="receipt.name" /></div>
+ <div><t t-esc="receipt.date.localestring" /></div>
+ </div>
+
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml
new file mode 100644
index 00000000000..9baf8b6b504
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ReceiptScreen" owl="1">
+ <div class="receipt-screen screen">
+ <div class="screen-content">
+ <div class="top-content">
+ <h1>
+ Change: <span class="change-value">
+ <t t-esc="change" /></span>
+ </h1>
+ <span class="button next" t-att-class="{ highlight: !locked }"
+ t-on-click="orderDone">
+ Next Order <i class="fa fa-angle-double-right"></i>
+ </span>
+ </div>
+ <div class="centered-content touch-scrollable">
+ <div t-if="state.printInvoiceIsShown" class="button print_invoice"
+ t-on-click="onPrintInvoice">
+ <i class="fa fa-print"></i> Print Invoice
+ </div>
+ <div class="button print" t-on-click="printReceipt">
+ <i class="fa fa-print"></i> Print Receipt
+ </div>
+ <div class="pos-receipt-container">
+ <OrderReceipt order="currentOrder" t-ref="order-receipt" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml
new file mode 100644
index 00000000000..d49061a82ac
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="WrappedProductNameLines" owl="1">
+ <span>
+ <t t-foreach="props.line.product_name_wrapped.slice(1)" t-as="wrapped_line"><t t-esc="wrapped_line"/></t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml
new file mode 100644
index 00000000000..de21dcc307a
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ScaleScreen" owl="1">
+ <div class="scale-screen screen">
+ <div class="screen-content">
+ <div class="top-content">
+ <span class="button back" t-on-click="back">
+ <i class="fa fa-angle-double-left"></i>
+ Back
+ </span>
+ <h1 class="product-name">
+ <t t-esc="productName" />
+ </h1>
+ </div>
+ <div class="centered-content">
+ <div class="weight js-weight">
+ <t t-esc="productWeightString" />
+ </div>
+ <div class="product-price">
+ <t
+ t-esc="env.pos.format_currency(productPrice) + '/' + productUom" />
+ </div>
+ <div class="computed-price">
+ <t t-esc="computedPriceString" />
+ </div>
+ <div class="buy-product" t-on-click="confirm">
+ Order
+ <i class="fa fa-angle-double-right"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/xml/debug_manager.xml b/addons/point_of_sale/static/src/xml/debug_manager.xml
new file mode 100644
index 00000000000..7ecc7cc1e6e
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/debug_manager.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+<t t-extend="WebClient.DebugManager.Backend">
+ <t t-jquery="a[data-action='perform_click_everywhere_test']" t-operation="after">
+ <a role="menuitem" href="#" data-action="perform_pos_js_tests" class="dropdown-item">Run Point of Sale JS Tests</a>
+ </t>
+</t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml
deleted file mode 100644
index b90c797b6bf..00000000000
--- a/addons/point_of_sale/static/src/xml/pos.xml
+++ /dev/null
@@ -1,1778 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates id="template" xml:space="preserve">
-
- <t t-name="Chrome">
- <div class='pos-receipt-print'>
- <!-- here go the receipts that will be printed -->
- </div>
-
- <div class="pos">
- <div class="pos-topheader">
- <div class="pos-branding">
- <img class="pos-logo" src="/point_of_sale/static/src/img/logo.png" alt="Logo"/>
- </div>
- <div class="pos-rightheader">
- <span class="placeholder-OrderSelectorWidget"></span>
- <!-- here goes header buttons -->
- <div class="oe_status">
- <span class="placeholder-UsernameWidget"></span>
- </div>
- </div>
- </div>
-
- <div class="pos-content">
-
- <div class='window'>
- <div class='subwindow'>
- <div class='subwindow-container'>
- <div class='subwindow-container-fix screens'>
- <!-- here go the screens -->
- </div>
- </div>
- </div>
- </div>
-
- <div class="placeholder-OnscreenKeyboardWidget"></div>
- </div>
-
- <div class='popups'>
- <!-- here go the popups -->
- </div>
-
- <div class="loader">
- <div class='loader-feedback oe_hidden'>
- <h1 class='message'>Loading</h1>
- <div class='progressbar'>
- <div class='progress' width='50%'></div>
- </div>
- <div class='oe_hidden button skip'>
- Skip
- </div>
- </div>
- </div>
-
- </div>
- </t>
-
- <t t-name="SynchNotificationWidget">
- <div class="oe_status js_synch">
- <span class='js_msg oe_hidden'>0</span>
- <div class="js_connected oe_icon oe_green">
- <i class='fa fa-fw fa-wifi' role="img" aria-label="Synchronisation Connected" title="Synchronisation Connected"></i>
- </div>
- <div class="js_connecting oe_icon oe_hidden">
- <i class='fa fa-fw fa-spin fa-spinner' role="img" aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i>
- </div>
- <div class="js_disconnected oe_icon oe_red oe_hidden">
- <i class='fa fa-fw fa-wifi' role="img" aria-label="Synchronisation Disconnected" title="Synchronisation Disconnected"></i>
- </div>
- <div class="js_error oe_icon oe_red oe_hidden">
- <i class='fa fa-fw fa-warning' role="img" aria-label="Synchronisation Error" title="Synchronisation Error"></i>
- </div>
- </div>
- </t>
-
- <t t-name="ProxyStatusWidget">
- <div class="oe_status js_proxy">
- <div class="js_connected oe_green">
- <span class="js_msg oe_hidden"></span>
- <i class='fa fa-fw fa-sitemap' role="img" aria-label="Proxy Connected" title="Proxy Connected"></i>
- </div>
- <div class="js_connecting oe_hidden">
- <span class="js_msg oe_hidden"></span>
- <i class='fa fa-fw fa-spin fa-spinner' role="img" aria-label="Connecting to Proxy" title="Connecting to Proxy"></i>
- </div>
- <div class="js_warning oe_orange oe_hidden">
- <span class="js_msg oe_hidden"></span>
- <i class='fa fa-fw fa-sitemap' role="img" aria-label="Proxy Warning" title="Proxy Warning"></i>
- </div>
- <div class="js_disconnected oe_red oe_hidden">
- <span class="js_msg oe_hidden"></span>
- <i class='fa fa-fw fa-sitemap' role="img" aria-label="Proxy Disconnected" title="Proxy Disconnected"></i>
- </div>
- </div>
- </t>
-
- <t t-name="SaleDetailsButton">
- <div class="oe_status">
- <div class="js_connected oe_icon">
- <i class='fa fa-fw fa-print' role="img" aria-label="Print" title="Print a report with all the sales of the current PoS Session"></i>
- </div>
- </div>
- </t>
-
- <t t-name="ClientScreenWidget">
- <div class="oe_status">
- <span class="oe_customer_display_text"></span>
- <div class="js_warning oe_icon oe_orange oe_hidden">
- <i class='fa fa-fw fa-desktop' role="img" aria-label="Client Screen Warning" title="Client Screen Warning"></i>
- </div>
- <div class="js_disconnected oe_icon oe_red">
- <i class='fa fa-fw fa-desktop' role="img" aria-label="Client Screen Disconnected" title="Client Screen Disconnected"></i>
- </div>
- <div class="js_connected oe_icon oe_green oe_hidden">
- <i class='fa fa-fw fa-desktop' role="img" aria-label="Client Screen Connected" title="Client Screen Connected"></i>
- </div>
- </div>
- </t>
-
- <t t-name="HeaderButtonWidget">
- <div class="header-button">
- <t t-if="!widget.icon">
- <t t-esc="widget.label" />
- </t>
- <t t-else="">
- <i class='fa fa-fw' role="img"/>
- </t>
- </div>
- </t>
-
- <t t-name="PosCloseWarning">
- <div>There are pending operations that could not be saved into the database, are you sure you want to exit?</div>
- </t>
-
- <t t-name="ActionButtonWidget">
- <div class='control-button'>
- <t t-esc='widget.label' />
- </div>
- </t>
-
- <t t-name="SetFiscalPositionButton">
- <div class='control-button o_fiscal_position_button'>
- <i class='fa fa-book' role="img" aria-label="Set fiscal position" title="Set fiscal position"/> <t t-esc='widget.get_current_fiscal_position_name()'/>
- </div>
- </t>
-
- <t t-name="SetPricelistButton">
- <div class='control-button o_pricelist_button'>
- <i class='fa fa-th-list' role="img" aria-label="Price list" title="Price list"/> <t t-esc='widget.get_current_pricelist_name()'/>
- </div>
- </t>
-
- <t t-name="ActionpadWidget">
- <div class="actionpad">
- <button t-attf-class='button set-customer #{ ( widget.pos.get_client() and widget.pos.get_client().name.length > 10) ? "decentered" : "" }' >
- <i class='fa fa-user' role="img" aria-label="Customer" title="Customer"/>
- <t t-if="widget.pos.get_client()">
- <t t-esc="widget.pos.get_client().name" />
- </t>
- <t t-if="!widget.pos.get_client()">
- Customer
- </t>
- </button>
- <button class='button pay'>
- <div class='pay-circle'>
- <i class='fa fa-chevron-right' role="img" aria-label="Pay" title="Pay"/>
- </div>
- Payment
- </button>
- </div>
- </t>
-
- <t t-name="NumpadWidget">
- <div class="numpad">
- <button class="input-button number-char">1</button>
- <button class="input-button number-char">2</button>
- <button class="input-button number-char">3</button>
- <button class="mode-button" data-mode='quantity'>Qty</button>
- <br />
- <button class="input-button number-char">4</button>
- <button class="input-button number-char">5</button>
- <button class="input-button number-char">6</button>
- <button class="mode-button" data-mode='discount'>Disc</button>
- <br />
- <button class="input-button number-char">7</button>
- <button class="input-button number-char">8</button>
- <button class="input-button number-char">9</button>
- <button class="mode-button" data-mode='price'>Price</button>
- <br />
- <button class="input-button numpad-minus" >+/-</button>
- <button class="input-button number-char">0</button>
- <button class="input-button number-char">.</button>
- <button class="input-button numpad-backspace">
- <img style="pointer-events: none;" src="/point_of_sale/static/src/img/backspace.png" width="24" height="21" alt="Backspace"/>
- </button>
- </div>
- </t>
-
- <t t-name="CategoryButton">
- <span class='category-button js-category-switch' t-att-data-category-id="category.id" >
- <div class="category-img">
- <img t-att-src="image_url" alt="Category"/>
- </div>
- <div class="category-name">
- <t t-esc="category.name"/>
- </div>
- </span>
- </t>
-
- <t t-name="CategorySimpleButton">
- <span class='category-simple-button js-category-switch' t-att-data-category-id="category.id" >
- <t t-esc="category.name"/>
- </span>
- </t>
-
- <t t-name="ProductCategoriesWidget">
- <div>
- <header class='rightpane-header'>
- <div class="breadcrumbs">
- <span class="breadcrumb">
- <span class=" breadcrumb-button breadcrumb-home js-category-switch">
- <i class='fa fa-home' role="img" aria-label="Home" title="Home"></i>
- </span>
- </span>
- <t t-foreach="widget.breadcrumb" t-as="category">
- <span class="breadcrumb">
- <img src="/point_of_sale/static/src/img/bc-arrow-big.png" class="breadcrumb-arrow" alt="Slash"/>
- <span class="breadcrumb-button js-category-switch" t-att-data-category-id="category.id">
- <t t-esc="category.name"/>
- </span>
- </span>
- </t>
- </div>
- <div class="searchbox">
- <input placeholder="Search Products" />
- <span class="search-clear left">
- <i class="fa fa-search" />
- </span>
- <span class="search-clear right">
- <i class="fa fa-remove" />
- </span>
- </div>
- </header>
- <t t-if="widget.subcategories.length > 0">
- <div class="categories">
- <div class="category-list-scroller touch-scrollable">
- <div class="category-list">
- </div>
- </div>
- </div>
- </t>
- </div>
- </t>
-
- <t t-name="ProductListWidget">
- <div class='product-list-container'>
- <div class="product-list-scroller touch-scrollable">
- <t t-if="widget.product_list.length != 0">
- <div class="product-list">
- </div>
- </t>
- <t t-else="">
- <div class="product-list-empty">
- <t t-if="widget.product_list.length == 0 &amp;&amp; !!widget.search_word">
- <p>No results found for "<b t-esc="widget.search_word"></b>".</p>
- </t>
- <t t-else="">
- <p>There are no products in this category.</p>
- </t>
- </div>
- </t>
- </div>
- <span class="placeholder-ScrollbarWidget" />
- </div>
- </t>
-
- <t t-name="ProductScreenWidget">
- <div class="product-screen screen">
- <div class="leftpane">
- <div class='window'>
- <div class='subwindow'>
- <div class='subwindow-container'>
- <div class='subwindow-container-fix'>
- <div class="placeholder-OrderWidget"></div>
- </div>
- </div>
- </div>
-
- <div class='subwindow collapsed'>
- <div class='subwindow-container'>
- <div class='subwindow-container-fix pads'>
- <div class="control-buttons oe_hidden"></div>
- <div class="placeholder-ActionpadWidget"></div>
- <div class="placeholder-NumpadWidget"></div>
- </div>
- </div>
- </div>
-
- </div>
- </div>
- <div class="rightpane">
- <table class="layout-table">
-
- <tr class="header-row">
- <td class="header-cell">
- <span class="placeholder-ProductCategoriesWidget" />
- </td>
- </tr>
-
- <tr class="content-row">
- <td class="content-cell">
- <div class="content-container">
- <span class="placeholder-ProductListWidget" />
- </div>
- </td>
- </tr>
-
- </table>
- </div>
- </div>
- </t>
-
- <t t-name="ScaleScreenWidget">
- <div class="scale-screen screen">
- <div class="screen-content">
- <div class="top-content">
- <span class='button back'>
- <i class='fa fa-angle-double-left'></i>
- Back
- </span>
- <h1 class='product-name'><t t-esc="widget.get_product_name()" /></h1>
- </div>
- <div class="centered-content">
- <div class='weight js-weight'>
- <t t-esc="widget.get_product_weight_string()" />
- </div>
- <div class='product-price'>
- <t t-esc="widget.format_currency(widget.get_product_price()) + '/' + widget.get_product_uom()" />
- </div>
- <div class='computed-price'>
- 123.14 €
- </div>
- <div class='buy-product'>
- Order
- <i class='fa fa-angle-double-right'></i>
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <t t-name="ClientLine">
- <tr class='client-line' t-att-data-id='partner.id'>
- <td><t t-esc='partner.name' /></td>
- <td><t t-esc='partner.address' /></td>
- <td><t t-esc='partner.phone or partner.mobile or ""' /></td>
- </tr>
- </t>
-
- <t t-name="ClientDetailsEdit">
- <section class='client-details edit'>
- <div class='client-picture'>
- <t t-if='!partner.id'>
- <i class='fa fa-camera' role="img" aria-label="Picture" title="Picture"></i>
- </t>
- <t t-if='partner.id'>
- <img t-att-src='widget.partner_icon_url(partner.id)' alt="Partner" style="width: 64px; height: 64px; object-fit: cover;"/>
- </t>
- <input type='file' class='image-uploader'></input>
- </div>
- <input class='detail client-name' name='name' t-att-value='partner.name' placeholder='Name'></input>
- <div class='edit-buttons'>
- <div class='button undo'><i class='fa fa-undo' role="img" aria-label="Undo" title="Undo"/></div>
- <div class='button save'><i class='fa fa-floppy-o' role="img" aria-label="Save" title="Save"/></div>
- </div>
- <div class='client-details-box clearfix'>
- <div class='client-details-left'>
- <div class='client-detail'>
- <span class='label'>Street</span>
- <input class='detail client-address-street' name='street' t-att-value='partner.street || ""' placeholder='Street'></input>
- </div>
- <div class='client-detail'>
- <span class='label'>City</span>
- <input class='detail client-address-city' name='city' t-att-value='partner.city || ""' placeholder='City'></input>
- </div>
- <div class='client-detail'>
- <span class='label'>Postcode</span>
- <input class='detail client-address-zip' name='zip' t-att-value='partner.zip || ""' placeholder='ZIP'></input>
- </div>
- <div class='client-detail'>
- <span class='label'>State</span>
- <select class='detail client-address-states needsclick' name='state_id'>
- <option value=''>None</option>
- <t t-foreach='widget.pos.states' t-as='state'>
- <option t-if="partner.country_id[0] == state.country_id[0]" t-att-value='state.id'
- t-att-selected="partner.state_id ? ((state.id === partner.state_id[0]) ? true : undefined) : undefined">
- <t t-esc='state.name'/>
- </option>
- </t>
- </select>
- </div>
- <div class='client-detail'>
- <span class='label'>Country</span>
- <select class='detail client-address-country needsclick' name='country_id'>
- <option value=''>None</option>
- <t t-foreach='widget.pos.countries' t-as='country'>
- <option t-att-value='country.id' t-att-selected="partner.country_id ? ((country.id === partner.country_id[0]) ? true : undefined) : undefined">
- <t t-esc='country.name'/>
- </option>
- </t>
- </select>
- </div>
- </div>
- <div class='client-detail'>
- <span class='label'>Language</span>
- <select class='detail client-lang needsclick' name='lang'>
- <t t-foreach='widget.pos.langs' t-as='lang'>
- <option t-att-value='lang.code' t-att-selected="partner.lang ? ((lang.code === partner.lang) ? true : undefined) : lang.code === widget.pos.user.lang? true : undefined">
- <t t-esc='lang.name'/>
- </option>
- </t>
- </select>
- </div>
- <div class='client-details-right'>
- <div class='client-detail'>
- <span class='label'>Email</span>
- <input class='detail client-email' name='email' type='email' t-att-value='partner.email || ""'></input>
- </div>
- <div class='client-detail'>
- <span class='label'>Phone</span>
- <input class='detail client-phone' name='phone' type='tel' t-att-value='partner.phone || ""'></input>
- </div>
- <div class='client-detail'>
- <span class='label'>Barcode</span>
- <input class='detail barcode' name='barcode' t-att-value='partner.barcode || ""'></input>
- </div>
- <div class='client-detail'>
- <span class='label'>Tax ID</span>
- <input class='detail vat' name='vat' t-att-value='partner.vat || ""'></input>
- </div>
- <div t-attf-class='client-detail #{widget.pos.pricelists.length &lt;= 1 ? "oe_hidden" : ""}'>
- <span class='label'>Pricelist</span>
- <select class='detail needsclick' name='property_product_pricelist'>
- <t t-foreach='widget.pos.pricelists' t-as='pricelist'>
- <option t-att-value='pricelist.id'
- t-att-selected="partner.property_product_pricelist ? (pricelist.id === partner.property_product_pricelist[0] ? true : undefined) : undefined">
- <t t-esc='pricelist.display_name'/>
- </option>
- </t>
- </select>
- </div>
- </div>
- </div>
- </section>
- </t>
- <t t-name="ClientDetails">
- <section class='client-details'>
- <div class='client-picture'>
- <img t-att-src='widget.partner_icon_url(partner.id)' alt="Partner logo" style="width: 64px; height: 64px; object-fit: cover;"/>
- </div>
- <div class='client-name'><t t-esc='partner.name' /></div>
- <div class='edit-buttons'>
- <div class='button edit'><i class='fa fa-pencil-square' role="img" aria-label="Edit" title="Edit"/></div>
- </div>
- <div class='client-details-box clearfix'>
- <div class='client-details-left'>
- <div class='client-detail'>
- <span class='label'>Address</span>
- <t t-if='partner.address'>
- <span class='detail client-address'><t t-esc='partner.address' /></span>
- </t>
- <t t-if='!partner.address'>
- <span class='detail client-address empty'>N/A</span>
- </t>
- </div>
- <div class='client-detail'>
- <span class='label'>Email</span>
- <t t-if='partner.email'>
- <span class='detail client-email'><t t-esc='partner.email' /></span>
- </t>
- <t t-if='!partner.email'>
- <span class='detail client-email empty'>N/A</span>
- </t>
- </div>
- <div class='client-detail'>
- <span class='label'>Phone</span>
- <t t-if='partner.phone'>
- <span class='detail client-phone'><t t-esc='partner.phone' /></span>
- </t>
- <t t-if='!partner.phone'>
- <span class='detail client-phone empty'>N/A</span>
- </t>
- </div>
- </div>
- <div class='client-details-right'>
- <div class='client-detail'>
- <span class='label'>Barcode</span>
- <t t-if='partner.barcode'>
- <span class='detail client-id'><t t-esc='partner.barcode'/></span>
- </t>
- <t t-if='!partner.barcode'>
- <span class='detail client-id empty'>N/A</span>
- </t>
- </div>
- <div class='client-detail'>
- <span class='label'>Tax ID</span>
- <t t-if='partner.vat'>
- <span class='detail vat'><t t-esc='partner.vat'/></span>
- </t>
- <t t-if='!partner.vat'>
- <span class='detail vat empty'>N/A</span>
- </t>
- </div>
- <div t-attf-class='client-detail #{widget.pos.pricelists.length &lt;= 1 ? "oe_hidden" : ""}'>
- <span class='label'>Pricelist</span>
- <t t-if='partner.property_product_pricelist'>
- <span class='detail property_product_pricelist'><t t-esc='partner.property_product_pricelist[1]'/></span>
- </t>
- <t t-if='!partner.property_product_pricelist'>
- <span class='detail property_product_pricelist empty'>N/A</span>
- </t>
- </div>
- </div>
- </div>
- </section>
- </t>
-
- <t t-name="ClientListScreenWidget">
- <div class="clientlist-screen screen">
- <div class="screen-content">
- <section class="top-content">
- <span class='button back'>
- <i class='fa fa-angle-double-left'></i>
- Cancel
- </span>
- <span class='searchbox'>
- <input placeholder='Search Customers' />
- <span class='search-clear'></span>
- </span>
- <span class='searchbox'></span>
- <span class='button new-customer' role="img" aria-label="Add a customer" title="Add a customer">
- <i class='fa fa-user'></i>
- <i class='fa fa-plus'></i>
- </span>
- <span class='button next oe_hidden highlight'>
- Select Customer
- <i class='fa fa-angle-double-right'></i>
- </span>
- </section>
- <section class="full-content">
- <div class='window'>
- <section class='subwindow collapsed'>
- <div class='subwindow-container collapsed'>
- <div class='subwindow-container-fix client-details-contents'>
- </div>
- </div>
- </section>
- <section class='subwindow'>
- <div class='subwindow-container'>
- <div class='subwindow-container-fix touch-scrollable scrollable-y'>
- <table class='client-list'>
- <thead>
- <tr>
- <th>Name</th>
- <th>Address</th>
- <th>Phone</th>
- </tr>
- </thead>
- <tbody class='client-list-contents'>
- </tbody>
- </table>
- </div>
- </div>
- </section>
- </div>
- </section>
- </div>
- </div>
- </t>
-
- <t t-name="PaymentScreenElectronicPayment">
- <div class="paymentline electronic_payment">
- <t t-if="line.payment_status == 'pending'">
- <div>
- Payment request pending
- </div>
- <div class='button send_payment_request highlight' title="Send Payment Request" t-att-data-cid='line.cid'>
- Send
- </div>
- </t>
- <t t-elif="line.payment_status == 'retry'">
- <div>
- Transaction cancelled
- </div>
- <div class='button send_payment_request highlight' title="Send Payment Request" t-att-data-cid='line.cid'>
- Retry
- </div>
- </t>
- <t t-elif="line.payment_status == 'force_done'">
- <div>
- Connection error
- </div>
- <div class='button send_force_done' title="Force Done" t-att-data-cid='line.cid'>
- Force done
- </div>
- </t>
- <t t-elif="line.payment_status == 'waitingCard'">
- <div>
- Waiting for card
- </div>
- <div class='button send_payment_cancel' title="Cancel Payment Request" t-att-data-cid='line.cid'>
- Cancel
- </div>
- </t>
- <t t-elif="['waiting', 'waitingCancel'].includes(line.payment_status)">
- <div>
- Request sent
- </div>
- <div>
- <i class='fa fa-spinner fa-spin' role='img'/>
- </div>
- </t>
- <t t-elif="line.payment_status == 'reversing'">
- <div>
- Reversal request sent to terminal
- </div>
- <div>
- <i class='fa fa-spinner fa-spin' role='img'/>
- </div>
- </t>
- <t t-elif="line.payment_status == 'done'">
- <t t-if="line.can_be_reversed">
- <div>
- Payment Successful
- </div>
- <div class='button send_payment_reversal' title="Reverse Payment" t-att-data-cid='line.cid'>
- Reverse
- </div>
- </t>
- <t t-else="">
- <div>
- Payment Successful
- </div>
- <div></div>
- </t>
- </t>
- <t t-elif="line.payment_status == 'reversed'">
- <div class="paymentline electronic_payment">
- <div>
- Payment reversed
- </div>
- <div></div>
- </div>
- </t>
- </div>
- </t>
-
- <t t-name="PaymentScreen-Paymentlines">
- <t t-if="!paymentlines.length">
- <div class='paymentlines-empty'>
- <div class='total'>
- <t t-esc="widget.format_currency(order.get_total_with_tax() + order.get_rounding_applied())"/>
- </div>
- <div class='message'>
- Please select a payment method.
- </div>
- </div>
- </t>
-
- <t t-if="paymentlines.length">
- <div class="payment-status-container">
- <div>
- <div class="payment-status-remaining">
- <span class="label">Remaining</span>
- <span t-att-class="order.get_due() > 0 ? 'amount highlight' : 'amount'">
- <t t-esc="widget.format_currency(order.get_due() > 0 ? order.get_due() : 0)" />
- </span>
- </div>
- <div class="payment-status-total-due">
- <span class="label">Total Due</span>
- <span>
- <t t-esc="widget.format_currency(order.get_total_with_tax() + order.get_rounding_applied())" />
- </span>
- </div>
- </div>
- <div>
- <div class="payment-status-change">
- <span class="label">Change</span>
- <span t-att-class="order.get_change() > 0 ? 'amount highlight' : 'amount'">
- <t t-esc="widget.format_currency(order.get_change())" />
- </span>
- </div>
- </div>
- </div>
- <hr />
- <div class='paymentlines'>
- <div class='paymentline heading'>
- <div>Tendered</div>
- <div>Method</div>
- <div></div>
- </div>
- <div class='body'>
- <t t-foreach='paymentlines' t-as='line'>
- <t t-if='line.selected'>
- <div t-attf-class="paymentline selected #{line.get_payment_status() ? 'payment-terminal' : ''}">
- <t t-if="line &amp;&amp; line.payment_status &amp;&amp; ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)">
- <div class='col-tendered'>
- <t t-esc='widget.format_currency_no_symbol(line.get_amount())' />
- </div>
- </t>
- <t t-else="">
- <div class='col-tendered edit'>
- <t t-esc='widget.inputbuffer' />
- </div>
- </t>
- <div class='col-name' > <t t-esc='line.payment_method.name' /> </div>
- <t t-if="line &amp;&amp; line.payment_status &amp;&amp; ['done', 'reversed', 'reversing'].includes(line.payment_status)">
- <div/>
- </t>
- <t t-else=''>
- <div class='delete-button' t-att-data-cid='line.cid' aria-label="Delete" title="Delete">
- <i class='fa fa-times-circle'/>
- </div>
- </t>
- </div>
- <t t-if="line &amp;&amp; line.payment_status">
- <t t-call="PaymentScreenElectronicPayment"/>
- </t>
- </t>
- <t t-if='!line.selected'>
- <div class='paymentline' t-att-data-cid='line.cid'>
- <div class='col-tendered'> <t t-esc='widget.format_currency_no_symbol(line.get_amount())' /> </div>
- <div class='col-name'> <t t-esc='line.payment_method.name' /> </div>
- <t t-if="line &amp;&amp; line.payment_status &amp;&amp; ['done', 'reversed', 'reversing'].includes(line.payment_status)">
- <div/>
- </t>
- <t t-else=''>
- <div class='delete-button' t-att-data-cid='line.cid' aria-label="Delete" title="Delete">
- <i class='fa fa-times-circle'/>
- </div>
- </t>
- </div>
- </t>
- </t>
- </div>
- </div>
- </t>
-
- </t>
-
- <t t-name="PaymentScreen-Numpad">
- <div class="numpad">
- <button class="input-button number-char" data-action='1'>1</button>
- <button class="input-button number-char" data-action='2'>2</button>
- <button class="input-button number-char" data-action='3'>3</button>
- <button class="mode-button" data-action='+10'>+10</button>
- <br />
- <button class="input-button number-char" data-action='4'>4</button>
- <button class="input-button number-char" data-action='5'>5</button>
- <button class="input-button number-char" data-action='6'>6</button>
- <button class="mode-button" data-action='+20'>+20</button>
- <br />
- <button class="input-button number-char" data-action='7'>7</button>
- <button class="input-button number-char" data-action='8'>8</button>
- <button class="input-button number-char" data-action='9'>9</button>
- <button class="mode-button" data-action='+50'>+50</button>
- <br />
- <button class="input-button numpad-char" data-action='-' >+/-</button>
- <button class="input-button number-char" data-action='0'>0</button>
- <button class="input-button number-char" t-att-data-action='widget.decimal_point'><t t-esc='widget.decimal_point'/></button>
- <button class="input-button numpad-backspace" data-action='BACKSPACE' >
- <img src="/point_of_sale/static/src/img/backspace.png" width="24" height="21" alt="Backspace"/>
- </button>
- </div>
- </t>
-
- <t t-name="PaymentScreen-Paymentmethods">
- <div class='paymentmethods'>
- <t t-foreach="widget.pos.payment_methods" t-as="payment_method">
- <div class="button paymentmethod" t-att-data-id="payment_method.id">
- <t t-esc="payment_method.name" />
- </div>
- </t>
- </div>
- </t>
-
- <t t-name="PaymentScreenWidget">
- <div class='payment-screen screen'>
- <div class='screen-content'>
- <div class='top-content'>
- <span class='button back'>
- <i class='fa fa-angle-double-left'></i>
- Back
- </span>
- <h1>Payment</h1>
- <span class='button next'>
- Validate
- <i class='fa fa-angle-double-right'></i>
- </span>
- </div>
- <div class='left-content pc40 touch-scrollable scrollable-y'>
-
- <div class='paymentmethods-container'>
- </div>
-
- </div>
- <div class='right-content pc60 touch-scrollable scrollable-y'>
-
- <section class='paymentlines-container'>
- </section>
-
- <section class='payment-numpad'>
- </section>
-
- <div class='payment-buttons'>
- <div class='button js_set_customer'>
- <i class='fa fa-user' role="img" aria-label="Customer" title="Customer"/>
- <span class='js_customer_name'>
- <t t-if="widget.pos.get_client()">
- <t t-esc="widget.pos.get_client().name" />
- </t>
- <t t-if="!widget.pos.get_client()">
- Customer
- </t>
- </span>
- </div>
- <t t-if='widget.pos.config.module_account'>
- <t t-if='widget.pos.get_order()'>
- <div t-attf-class='button js_invoice #{ widget.pos.get_order().is_to_invoice() ? "highlight" : ""} '>
- <i class='fa fa-file-text-o' /> Invoice
- </div>
- </t>
- </t>
- <div class='button js_email'>
- <i class='fa fa-inbox' /> Email
- </div>
- <t t-if='widget.pos.config.tip_product_id'>
- <div class='button js_tip'>
- <i class='fa fa-heart' /> Tip
- </div>
- </t>
- <t t-if='widget.pos.config.iface_cashdrawer'>
- <div class='button js_cashdrawer'>
- <i class='fa fa-archive' /> Open Cashbox
- </div>
- </t>
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <t t-name="ReceiptScreenWidget">
- <div class='receipt-screen screen'>
- <div class='screen-content'>
- <div class='top-content'>
- <h1>Change: <span class='change-value'>0.00</span></h1>
- <span class='button next'>
- Next Order
- <i class='fa fa-angle-double-right'></i>
- </span>
- </div>
- <div class="centered-content touch-scrollable">
- <h2 class="print_invoice">The order has been synchronized earlier. To print the invoice please refer to the order in the backend</h2>
- <div class="button print">
- <i class='fa fa-print'></i> Print Receipt
- </div>
- <div class="pos-receipt-container">
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <t t-name="OrderReceiptWrappedProductNameLines"><t t-foreach="line.product_name_wrapped.slice(1)" t-as="wrapped_line"><t t-esc="wrapped_line"/></t></t>
-
- <t t-name="OrderReceipt">
- <div class="pos-receipt">
- <t t-if='receipt.company.logo'>
- <img class="pos-receipt-logo" t-att-src='receipt.company.logo' alt="Logo"/>
- <br/>
- </t>
- <t t-if='!receipt.company.logo'>
- <h2 class="pos-receipt-center-align">
- <t t-esc='receipt.company.name' />
- </h2>
- <br/>
- </t>
- <div class="pos-receipt-contact">
- <t t-if='receipt.company.contact_address'>
- <div><t t-esc='receipt.company.contact_address' /></div>
- </t>
- <t t-if='receipt.company.phone'>
- <div>Tel:<t t-esc='receipt.company.phone' /></div>
- </t>
- <t t-if='receipt.company.vat'>
- <div>VAT:<t t-esc='receipt.company.vat' /></div>
- </t>
- <t t-if='receipt.company.email'>
- <div><t t-esc='receipt.company.email' /></div>
- </t>
- <t t-if='receipt.company.website'>
- <div><t t-esc='receipt.company.website' /></div>
- </t>
- <t t-if='receipt.header_html'>
- <t t-raw='receipt.header_html' />
- </t>
- <t t-if='!receipt.header_html and receipt.header'>
- <div><t t-esc='receipt.header' /></div>
- </t>
- <t t-if='receipt.cashier'>
- <div class='cashier'>
- <div>--------------------------------</div>
- <div>Served by <t t-esc='receipt.cashier' /></div>
- </div>
- </t>
- </div>
- <br /><br />
-
- <!-- Orderlines -->
-
- <div class='orderlines'>
- <t t-foreach='receipt.orderlines' t-as='line'>
- <t t-set='simple' t-value='(line.discount === 0 and line.unit_name === "Units" and line.quantity === 1 and !discount_to_show and !(line.display_discount_policy == "without_discount" &amp;&amp; line.price != line.price_lst))' />
- <t t-if='simple'>
- <div><t t-esc='line.product_name_wrapped[0]' /><span t-esc='widget.format_currency_no_symbol(line.price_display)' class="price_display pos-receipt-right-align"/></div><t t-call="OrderReceiptWrappedProductNameLines"/></t>
- <t t-if='!simple'>
- <div t-esc='line.product_name_wrapped[0]'/><t t-call="OrderReceiptWrappedProductNameLines"/><t t-if="line.display_discount_policy == 'without_discount' &amp;&amp; line.price != line.price_lst"><div class="pos-receipt-left-padding"><t t-esc="widget.format_currency_no_symbol(line.price_lst)" />-><t t-esc="widget.format_currency_no_symbol(line.price)" /></div>
- </t>
- <t t-elif='line.discount !== 0'>
- <div class="pos-receipt-left-padding">
- <t t-if="pos.config.iface_tax_included === 'total'">
- <t t-esc="widget.format_currency_no_symbol(line.price_with_tax_before_discount)"/>
- </t>
- <t t-else="">
- <t t-esc="widget.format_currency_no_symbol(line.price)"/>
- </t>
- </div>
- </t>
- <t t-if='line.discount !== 0'>
- <div class="pos-receipt-left-padding">
- Discount: <t t-esc='line.discount' />%
- </div>
- </t>
- <div class="pos-receipt-left-padding">
- <t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])"/>
- <t t-if='line.unit_name !== "Units"' t-esc='line.unit_name'/>
- x
- <t t-esc='widget.format_currency_no_symbol(line.price_display_one)' />
- <span class="price_display pos-receipt-right-align">
- <t t-esc='widget.format_currency_no_symbol(line.price_display)'/>
- </span>
- </div>
- </t>
- </t>
- </div>
-
- <!-- Subtotal -->
-
- <t t-set='taxincluded' t-value='Math.abs(receipt.subtotal - receipt.total_with_tax) &lt;= 0.000001' />
- <t t-if='!taxincluded'>
- <div class="pos-receipt-right-align">--------</div>
- <br/>
- <div>Subtotal<span t-esc='widget.format_currency(receipt.subtotal)' class="pos-receipt-right-align"/></div>
- <t t-foreach='receipt.tax_details' t-as='tax'>
- <div>
- <t t-esc='tax.name' />
- <span t-esc='widget.format_currency_no_symbol(tax.amount)' class="pos-receipt-right-align"/>
- </div>
- </t>
- </t>
-
- <!-- Total -->
- <div class="pos-receipt-right-align">--------</div>
- <br/>
- <div class="pos-receipt-amount">
- TOTAL
- <span t-esc='widget.format_currency(receipt.total_with_tax)' class="pos-receipt-right-align"/>
- </div>
- <t t-if="receipt.total_rounded != receipt.total_with_tax">
- <div class="pos-receipt-amount">
- ROUNDED
- <span t-esc='widget.format_currency(receipt.total_rounded)' class="pos-receipt-right-align"/>
- </div>
- </t>
- <br/><br/>
-
- <!-- Payment Lines -->
-
- <t t-foreach='paymentlines' t-as='line'>
- <div>
- <t t-esc='line.name' />
- <span t-esc='widget.format_currency_no_symbol(line.get_amount())' class="pos-receipt-right-align"/>
- </div>
- </t>
- <br/>
-
- <div class="pos-receipt-amount receipt-change">
- CHANGE
- <span t-esc='widget.format_currency(receipt.change)' class="pos-receipt-right-align"/>
- </div>
- <br/>
-
- <!-- Extra Payment Info -->
-
- <t t-if='receipt.total_discount'>
- <div>
- Discounts
- <span t-esc='widget.format_currency(receipt.total_discount)' class="pos-receipt-right-align"/>
- </div>
- </t>
- <t t-if='taxincluded'>
- <t t-foreach='receipt.tax_details' t-as='tax'>
- <div>
- <t t-esc='tax.name' />
- <span t-esc='widget.format_currency_no_symbol(tax.amount)' class="pos-receipt-right-align"/>
- </div>
- </t>
- <div>
- Total Taxes
- <span t-esc='widget.format_currency(receipt.total_tax)' class="pos-receipt-right-align"/>
- </div>
- </t>
-
- <div class='before-footer' />
-
- <!-- Footer -->
- <div t-if='receipt.footer_html' class="pos-receipt-center-align">
- <t t-raw='receipt.footer_html'/>
- </div>
-
- <div t-if='!receipt.footer_html and receipt.footer' class="pos-receipt-center-align">
- <br/>
- <t t-esc='receipt.footer'/>
- <br/>
- <br/>
- </div>
-
- <div class='after-footer'>
- <t t-foreach='paymentlines' t-as='line'>
- <t t-if='line.ticket'>
- <br />
- <div class="pos-payment-terminal-receipt">
- <t t-raw='line.ticket'/>
- </div>
- </t>
- </t>
- </div>
-
- <br/>
- <div class="pos-receipt-order-data">
- <div><t t-esc='receipt.name' /></div>
- <div><t t-esc='receipt.date.localestring' /></div>
- </div>
-
- </div>
- </t>
-
- <t t-name="SaleDetailsReport">
- <div class="pos-receipt">
- <t t-if='pos.company_logo_base64'>
- <img class="pos-receipt-logo" t-att-src='pos.company_logo_base64' alt="Logo"/>
- <br/>
- </t>
- <t t-if='!pos.company_logo_base64' class="pos-receipt-center-align">
- <h1 t-esc='company.name' />
- <br/>
- </t>
- <br /><br />
-
- <!-- Orderlines -->
-
- <div class='orderlines'>
- <t t-foreach='products' t-as='line'>
- <div>
- <t t-esc='line.product_name.substr(0,20)' />
- <span class="pos-receipt-right-align">
- <t t-esc='Math.round(line.quantity * Math.pow(10, pos.dp["Product Unit of Measure"])) / Math.pow(10, pos.dp["Product Unit of Measure"])' />
- <t t-if='line.uom !== "Units"'>
- <t t-esc='line.uom' />
- </t>
- x
- <t t-esc='widget.format_currency_no_symbol(line.price_unit)' />
- </span>
- </div>
- <t t-if='line.discount !== 0'>
- <div class="pos-receipt-left-padding">Discount: <t t-esc='line.discount' />%</div>
- </t>
- </t>
- </div>
-
- <br/>
- <div>------------------------</div>
- <br/>
-
- <div>
- Payments:
- </div>
- <div t-foreach='payments' t-as='payment'>
- <t t-esc='payment.name'/>
- <span t-esc='widget.format_currency_no_symbol(payment.total)' class="pos-receipt-right-align"/>
- </div>
-
- <br/>
- <div>------------------------</div>
- <br/>
-
- <div>
- Taxes:
- </div>
- <div t-foreach='taxes' t-as='taxe'>
- <t t-esc='taxe.name'/>
- <span t-esc='widget.format_currency_no_symbol(taxe.tax_amount)' class="pos-receipt-right-align"/>
- </div>
-
- <br/>
- <div>------------------------</div>
- <br/>
-
- <div>
- Total:
- <span t-esc='widget.format_currency_no_symbol(total_paid)' class="pos-receipt-right-align"/>
- </div>
-
- <br/>
- <div class="pos-receipt-order-data">
- <div><t t-esc='date' /></div>
- </div>
-
- </div>
- </t>
-
-
- <t t-name="PopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-alert">
- <p class="title"><t t-esc=" widget.options.title || 'Alert' " /></p>
- <p class="body"><t t-esc=" widget.options.body || '' "/></p>
- <div class="footer">
- <div class="button cancel">
- Ok
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <t t-name="ErrorPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-error">
- <p class="title"><t t-esc=" widget.options.title || 'Error' " /></p>
- <p class="body"><t t-esc=" widget.options.body || '' "/></p>
- <div class="footer">
- <div class="button cancel">
- Ok
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <t t-name="SyncErrorPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-error">
- <header class="title"><t t-esc=" widget.options.title || 'Error' " /></header>
- <main class="body traceback"><t t-esc=" widget.options.body || '' "/></main>
- <footer class="footer">
- <div class="button cancel">
- Ok
- </div>
- <div class="button stop_showing_sync_errors">
- Don't show again
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="ErrorTracebackPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-error">
- <header class="title"><t t-esc=" widget.options.title || 'Error' " /></header>
- <main class="body traceback"><t t-esc=" widget.options.body || '' "/></main>
- <footer class="footer">
- <div class="button cancel">
- Ok
- </div>
- <a><div class="button icon download_error_file oe_hidden">
- <i class="fa fa-arrow-down" role="img" aria-label="Download error" title="Download error"></i>
- </div></a>
- <div class="button icon download">
- <i class="fa fa-download" role="img" aria-label="Download" title="Download"></i>
- </div>
- <div class="button icon email">
- <i class="fa fa-paper-plane" role="img" aria-label="Send by email" title="Send by email"></i>
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="ErrorBarcodePopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-barcode">
- <header class="title">Unknown Barcode
- <br />
- <span class='barcode'><t t-esc="widget.options.barcode" /></span>
- </header>
- <main class="body">
- The Point of Sale could not find any product, client, employee
- or action associated with the scanned barcode.
- </main>
- <footer class="footer">
- <div class="button cancel">
- Ok
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="ConfirmPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-confirm">
- <header class="title"><t t-esc=" widget.options.title || 'Confirm ?' " /></header>
- <main class="body"><t t-esc=" widget.options.body || '' "/></main>
- <footer class="footer">
- <div class="button confirm">
- Confirm
- </div>
- <div class="button cancel">
- Cancel
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="TextInputPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-textinput">
- <header class="title"><t t-esc=" widget.options.title || '' " /></header>
- <input type='text' t-att-value="widget.options.value || ''"></input>
- <div class="footer">
- <div class="button confirm">
- Ok
- </div>
- <div class="button cancel">
- Cancel
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <t t-name="PackLotLinePopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-text">
- <header class="title"><t t-esc=" widget.options.title || '' " /></header>
- <main class='packlot-lines'>
- <t t-if="widget.options.pack_lot_lines">
- <t t-set="focus_lot_line" t-value="widget.focus_model || widget.options.pack_lot_lines.get_empty_model()"/>
- <t t-foreach='widget.options.pack_lot_lines.models' t-as='lot_line'>
- <input type="text" t-att-cid="lot_line.cid"
- t-att-value="lot_line.get('lot_name')"
- t-att-autofocus="lot_line === focus_lot_line ? 'autofocus': undefined"
- class="popup-input packlot-line-input"
- placeholder="Serial/Lot Number"/>
- <i class="oe_link_icon remove-lot fa fa-trash-o" role="img" aria-label="Remove" title="Remove"></i>
- </t>
- </t>
- </main>
- <footer class="footer">
- <div class="button confirm">
- Ok
- </div>
- <div class="button cancel">
- Cancel
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="TextAreaPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-textinput">
- <header class="title"><t t-esc=" widget.options.title || '' " /></header>
- <textarea><t t-esc="widget.options.value" /></textarea>
- <footer class="footer">
- <div class="button confirm">
- Ok
- </div>
- <div class="button cancel">
- Cancel
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="SelectionPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-selection">
- <header class="title"><t t-esc=" widget.options.title || 'Select' " /></header>
- <div class='selection scrollable-y touch-scrollable'>
- <t t-foreach="widget.list || []" t-as="item">
- <div t-attf-class="selection-item {{ widget.is_selected.call(widget, item.item) ? 'selected' : '' }}" t-att-data-item-index='item_index'>
- <t t-esc='item.label'/>
- </div>
- </t>
- </div>
- <footer class="footer">
- <div class="button cancel">
- Cancel
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="NumberPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-number">
- <header class="title"><t t-esc=" widget.options.title || '' " /></header>
- <div class='popup-input value active'>
- <t t-esc='widget.inputbuffer' />
- </div>
- <div class='popup-numpad'>
- <button class="input-button number-char" data-action='1'>1</button>
- <button class="input-button number-char" data-action='2'>2</button>
- <button class="input-button number-char" data-action='3'>3</button>
- <t t-if="widget.options.cheap">
- <button class="mode-button add" data-action='+1'>+1</button>
- </t><t t-if="!widget.options.cheap">
- <button class="mode-button add" data-action='+10'>+10</button>
- </t>
- <br />
- <button class="input-button number-char" data-action='4'>4</button>
- <button class="input-button number-char" data-action='5'>5</button>
- <button class="input-button number-char" data-action='6'>6</button>
- <t t-if="widget.options.cheap">
- <button class="mode-button add" data-action='+2'>+2</button>
- </t><t t-if="!widget.options.cheap">
- <button class="mode-button add" data-action='+20'>+20</button>
- </t>
- <br />
- <button class="input-button number-char" data-action='7'>7</button>
- <button class="input-button number-char" data-action='8'>8</button>
- <button class="input-button number-char" data-action='9'>9</button>
- <t t-if="widget.options.cheap">
- <button class="mode-button add" data-action='+5'>+5</button>
- </t><t t-if="!widget.options.cheap">
- <button class="mode-button add" data-action='+50'>+50</button>
- </t>
- <br />
- <button class="input-button numpad-char" data-action='CLEAR' >C</button>
- <button class="input-button number-char" data-action='0'>0</button>
- <button class="input-button number-char dot" t-att-data-action='widget.decimal_separator'><t t-esc='widget.decimal_separator'/></button>
- <button class="input-button numpad-backspace" data-action='BACKSPACE' >
- <img style="pointer-events: none;" src="/point_of_sale/static/src/img/backspace.png" width="24" height="21" alt="Backspace"/>
- </button>
- <br />
- </div>
- <footer class="footer centered">
- <div class="button cancel">
- Cancel
- </div>
- <div class="button confirm">
- Ok
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="OrderImportPopupWidget">
- <div role="dialog" class="modal-dialog">
- <div class="popup popup-import">
- <header class="title">Finished Importing Orders</header>
- <t t-if="widget.options and widget.options.report">
- <t t-set="report" t-value="widget.options.report" />
- <t t-set="unpaid_skipped" t-value="(report.unpaid_skipped_existing || 0) + (report.unpaid_skipped_session || 0)" />
- <ul class='body'>
- <li>Successfully imported <b><t t-esc='report.paid || 0'/></b> paid orders</li>
- <li>Successfully imported <b><t t-esc='report.unpaid || 0'/></b> unpaid orders</li>
- <t t-if="unpaid_skipped">
- <li><b><t t-esc="unpaid_skipped"/></b> unpaid orders could not be imported
- <ul>
- <li><b><t t-esc="report.unpaid_skipped_existing || 0" /></b> were duplicates of existing orders</li>
- <li><b><t t-esc="report.unpaid_skipped_session || 0" /></b> belong to another session:
- <t t-if="report.unpaid_skipped_sessions">
- <ul>
- <li>Session ids: <b><t t-esc="JSON.stringify(report.unpaid_skipped_sessions)" /></b></li>
- </ul>
- </t>
- </li>
-
- </ul>
- </li>
- </t>
- </ul>
- </t>
- <footer class="footer">
- <div class="button cancel">
- Ok
- </div>
- </footer>
- </div>
- </div>
- </t>
-
- <t t-name="Product">
- <article class='product' t-att-data-product-id="product.id" tabindex="0" t-attf-aria-labelledby="article_product_#{product.id}">
- <div class="product-img">
- <img t-att-src='image_url' alt="Product image"/>
- <t t-if="!product.to_weight">
- <span class="price-tag">
- <t t-esc="widget.format_currency(product.get_price(pricelist, 1),'Product Price')"/>
- </span>
- </t>
- <t t-if="product.to_weight">
- <span class="price-tag">
- <t t-esc="widget.format_currency(product.get_price(pricelist, 1),'Product Price')+'/'+widget.pos.units_by_id[product.uom_id[0]].name"/>
- </span>
- </t>
- </div>
- <div class="product-name" t-attf-id="article_product_#{product.id}">
- <t t-esc="product.display_name"/>
- </div>
- </article>
- </t>
-
- <t t-name="Orderline">
- <li t-attf-class="orderline #{ line.selected ? 'selected' : '' }">
- <span class="product-name">
- <t t-esc="line.get_product().display_name"/>
- <t t-if="line.get_product().tracking!=='none'">
- <i class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_green" t-if="line.has_valid_product_lot()"
- aria-label="Valid product lot" role="img" title="Valid product lot"/>
- <i class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_red" t-if="!line.has_valid_product_lot()"
- aria-label="Invalid product lot" role="img" title="Invalid product lot"/>
- </t>
- </span>
- <span class="price">
- <t t-esc="widget.format_currency(line.get_display_price())"/>
- </span>
- <ul class="info-list">
- <t t-if="line.get_quantity_str() !== '1' || line.selected ">
- <li class="info">
- <em>
- <t t-esc="line.get_quantity_str()" />
- </em>
- <t t-esc="line.get_unit().name" />
- at
- <t t-if="line.display_discount_policy() == 'without_discount' &amp;&amp;
- line.get_unit_display_price() != line.get_lst_price()">
- <s>
- <t t-esc="widget.format_currency(line.get_fixed_lst_price(),'Product Price')" />
- </s>
- <t t-esc="widget.format_currency(line.get_unit_display_price(),'Product Price')" />
- </t>
- <t t-else="">
- <t t-esc="widget.format_currency(line.get_unit_display_price(),'Product Price')" />
- </t>
- /
- <t t-esc="line.get_unit().name" />
- </li>
- </t>
- <t t-if="line.get_discount_str() !== '0'">
- <li class="info">
- With a
- <em>
- <t t-esc="line.get_discount_str()" />%
- </em>
- discount
- </li>
- </t>
- </ul>
- </li>
- </t>
-
- <t t-name="OrderWidget">
- <div class="order-container">
- <div class="order-scroller touch-scrollable">
- <div class="order">
- <t t-if="orderlines.length === 0" >
- <div class='order-empty'>
- <i class='fa fa-shopping-cart' role="img" aria-label="Shopping cart" title="Shopping cart"/>
- <h1>Your shopping cart is empty</h1>
- </div>
- </t>
- <t t-if="orderlines.length > 0">
- <ul class="orderlines"></ul>
- <div class="summary clearfix">
- <div class="line">
- <div class='entry total'>
- <span class="badge">Total: </span> <span class="value">0.00 €</span>
- <div class='subentry'>Taxes: <span class="value">0.00€</span></div>
- </div>
- </div>
- </div>
- </t>
- </div>
- </div>
- </div>
- </t>
-
-
-
- <t t-name="DebugWidget">
- <div class="debug-widget oe_hidden">
- <h1>Debug Window</h1>
- <div class="toggle" title="Dismiss" role="img" aria-label="Dismiss"><i class='fa fa-times' /></div>
- <div class="content">
- <p class="category">Electronic Scale</p>
- <ul>
- <li><input type="text" class="weight"></input></li>
- <li class="button set_weight">Set Weight</li>
- <li class="button reset_weight">Reset</li>
- </ul>
-
- <p class="category">Barcode Scanner</p>
- <ul>
- <li><input type="text" class="ean"></input></li>
- <li class="button barcode">Scan</li>
- <li class="button custom_ean">Scan EAN-13</li>
- </ul>
-
- <p class="category">Orders</p>
-
- <ul>
- <li class="button delete_orders">Delete Paid Orders</li>
- <li class="button delete_unpaid_orders">Delete Unpaid Orders</li>
- <li class="button export_paid_orders">Export Paid Orders</li>
- <a><li class="button download_paid_orders oe_hidden">Download Paid Orders</li></a>
- <li class="button export_unpaid_orders">Export Unpaid Orders</li>
- <a><li class="button download_unpaid_orders oe_hidden">Download Unpaid Orders</li></a>
- <li class="button import_orders" style="position:relative">
- Import Orders
- <input type="file" style="opacity:0;position:absolute;top:0;left:0;right:0;bottom:0;margin:0;cursor:pointer"/>
- </li>
- </ul>
-
- <p class="category">Hardware Status</p>
- <ul>
- <li class="status weighing">Weighing</li>
- <li class="button display_refresh">Refresh Display</li>
- </ul>
- <p class="category">Hardware Events</p>
- <ul>
- <li class="event open_cashbox">Open Cashbox</li>
- <li class="event print_receipt">Print Receipt</li>
- <li class="event scale_read">Read Weighing Scale</li>
- </ul>
- </div>
- </div>
- </t>
-
-
- <t t-name="Paymentline">
- <div t-attf-class="paymentline #{line.selected ? 'selected' : ''}">
- <div class='paymentline-name'>
- <t t-esc="line.payment_method.name"/>
- </div>
- <input class='paymentline-input'
- t-att-type="widget.decimal_point === '.' ? 'number' : 'text'"
- t-attf-pattern="[0-9]+([\\#{widget.decimal_point || '.' }][0-9]+)?"
- step="0.01" t-att-value="line.get_amount_str()" />
- <span class='paymentline-delete'>
- <img src="/point_of_sale/static/src/img/search_reset.gif" alt="Delete"/>
- </span>
- </div>
- </t>
-
- <t t-name="PaymentlineOld">
- <tr t-attf-class="paymentline #{line.selected ? 'selected' : ''}">
- <td class="paymentline-type">
- <t t-esc="line.payment_method.name"/>
- </td>
- <td class="paymentline-amount pos-right-align">
- <input type="number" step="0.01" t-att-value="line.get_amount_str()" />
- <span class='delete-payment-line'><img src="/point_of_sale/static/src/img/search_reset.gif" alt="Delete"/></span>
- </td>
- </tr>
- </t>
-
- <t t-name="OrderSelectorWidget">
- <div class="order-selector">
- <span class="orders touch-scrollable">
-
- <t t-foreach="widget.pos.get_order_list()" t-as="order">
- <t t-if="order === widget.pos.get_order()">
- <span class="order-button select-order selected" t-att-data-uid="order.uid">
- <span class="order-sequence">
- <t t-esc="order.sequence_number" />
- </span>
- <t t-esc="moment(order.creation_date).format('hh:mm')"/>
- </span>
- </t>
- <t t-if="order !== widget.pos.get_order()">
- <span class="order-button select-order" t-att-data-uid="order.uid">
- <span class="order-sequence">
- <t t-esc="order.sequence_number" />
- </span>
- </span>
- </t>
- </t>
- </span>
- <span class="order-button square neworder-button">
- <i class='fa fa-plus' role="img" aria-label="New order" title="New order"/>
- </span>
- <span class="order-button square deleteorder-button">
- <i class='fa fa-minus' role="img" aria-label="Delete order" title="Delete order"/>
- </span>
- </div>
- </t>
-
- <t t-name="UsernameWidget">
- <span class="username">
- <t t-esc="widget.get_name()" />
- </span>
- </t>
-
- <!-- Onscreen Keyboard :
- http://net.tutsplus.com/tutorials/javascript-ajax/creating-a-keyboard-with-css-and-jquery/ -->
- <t t-name="OnscreenKeyboardFull">
- <div class="keyboard_frame">
- <ul class="keyboard full_keyboard">
- <li class="symbol"><span class="off">`</span><span class="on">~</span></li>
- <li class="symbol"><span class="off">1</span><span class="on">!</span></li>
- <li class="symbol"><span class="off">2</span><span class="on">@</span></li>
- <li class="symbol"><span class="off">3</span><span class="on">#</span></li>
- <li class="symbol"><span class="off">4</span><span class="on">$</span></li>
- <li class="symbol"><span class="off">5</span><span class="on">%</span></li>
- <li class="symbol"><span class="off">6</span><span class="on">^</span></li>
- <li class="symbol"><span class="off">7</span><span class="on">&amp;</span></li>
- <li class="symbol"><span class="off">8</span><span class="on">*</span></li>
- <li class="symbol"><span class="off">9</span><span class="on">(</span></li>
- <li class="symbol"><span class="off">0</span><span class="on">)</span></li>
- <li class="symbol"><span class="off">-</span><span class="on">_</span></li>
- <li class="symbol"><span class="off">=</span><span class="on">+</span></li>
- <li class="delete lastitem">delete</li>
- <li class="tab firstitem">tab</li>
- <li class="letter">q</li>
- <li class="letter">w</li>
- <li class="letter">e</li>
- <li class="letter">r</li>
- <li class="letter">t</li>
- <li class="letter">y</li>
- <li class="letter">u</li>
- <li class="letter">i</li>
- <li class="letter">o</li>
- <li class="letter">p</li>
- <li class="symbol"><span class="off">[</span><span class="on">{</span></li>
- <li class="symbol"><span class="off">]</span><span class="on">}</span></li>
- <li class="symbol lastitem"><span class="off">\</span><span class="on">|</span></li>
- <li class="capslock firstitem">caps lock</li>
- <li class="letter">a</li>
- <li class="letter">s</li>
- <li class="letter">d</li>
- <li class="letter">f</li>
- <li class="letter">g</li>
- <li class="letter">h</li>
- <li class="letter">j</li>
- <li class="letter">k</li>
- <li class="letter">l</li>
- <li class="symbol"><span class="off">;</span><span class="on">:</span></li>
- <li class="symbol"><span class="off">'</span><span class="on">&quot;</span></li>
- <li class="return lastitem">return</li>
- <li class="left-shift firstitem">shift</li>
- <li class="letter">z</li>
- <li class="letter">x</li>
- <li class="letter">c</li>
- <li class="letter">v</li>
- <li class="letter">b</li>
- <li class="letter">n</li>
- <li class="letter">m</li>
- <li class="symbol"><span class="off">,</span><span class="on">&lt;</span></li>
- <li class="symbol"><span class="off">.</span><span class="on">&gt;</span></li>
- <li class="symbol"><span class="off">/</span><span class="on">?</span></li>
- <li class="right-shift lastitem">shift</li>
- <li class="space firstitem lastitem">&amp;nbsp;</li>
- </ul>
- <p class="close_button">close</p>
- </div>
- </t>
-
- <t t-name="OnscreenKeyboardSimple">
- <div class="keyboard_frame">
- <ul class="keyboard simple_keyboard">
- <li class="symbol firstitem row_qwerty"><span class="off">q</span><span class="on">1</span></li>
- <li class="symbol"><span class="off">w</span><span class="on">2</span></li>
- <li class="symbol"><span class="off">e</span><span class="on">3</span></li>
- <li class="symbol"><span class="off">r</span><span class="on">4</span></li>
- <li class="symbol"><span class="off">t</span><span class="on">5</span></li>
- <li class="symbol"><span class="off">y</span><span class="on">6</span></li>
- <li class="symbol"><span class="off">u</span><span class="on">7</span></li>
- <li class="symbol"><span class="off">i</span><span class="on">8</span></li>
- <li class="symbol"><span class="off">o</span><span class="on">9</span></li>
- <li class="symbol lastitem"><span class="off">p</span><span class="on">0</span></li>
-
- <li class="symbol firstitem row_asdf"><span class="off">a</span><span class="on">@</span></li>
- <li class="symbol"><span class="off">s</span><span class="on">#</span></li>
- <li class="symbol"><span class="off">d</span><span class="on">%</span></li>
- <li class="symbol"><span class="off">f</span><span class="on">*</span></li>
- <li class="symbol"><span class="off">g</span><span class="on">/</span></li>
- <li class="symbol"><span class="off">h</span><span class="on">-</span></li>
- <li class="symbol"><span class="off">j</span><span class="on">+</span></li>
- <li class="symbol"><span class="off">k</span><span class="on">(</span></li>
- <li class="symbol lastitem"><span class="off">l</span><span class="on">)</span></li>
-
- <li class="symbol firstitem row_zxcv"><span class="off">z</span><span class="on">?</span></li>
- <li class="symbol"><span class="off">x</span><span class="on">!</span></li>
- <li class="symbol"><span class="off">c</span><span class="on">"</span></li>
- <li class="symbol"><span class="off">v</span><span class="on">'</span></li>
- <li class="symbol"><span class="off">b</span><span class="on">:</span></li>
- <li class="symbol"><span class="off">n</span><span class="on">;</span></li>
- <li class="symbol"><span class="off">m</span><span class="on">,</span></li>
- <li class="delete lastitem">delete</li>
-
- <li class="numlock firstitem row_space"><span class="off">123</span><span class="on">ABC</span></li>
- <li class="space">&amp;nbsp;</li>
- <li class="symbol"><span class="off">.</span><span class="on">.</span></li>
- <li class="return lastitem">return</li>
- </ul>
- <p class="close_button">close</p>
- </div>
- </t>
-
- <t t-name="CustomerFacingDisplayHead">
- <div class="resources">
- <base t-att-href="origin"/>
- <link href="/point_of_sale/static/src/css/customer_facing_display.css" rel="stylesheet"/>
- <script type="text/javascript">
- // This function needs to be named that way, call it the foreign JS API
- // The iotbox will execute it, with the behavior intended
- function foreign_js(){
- if ($('.pos-adv').hasClass('pos-hidden')) {
- $('.pos-customer_facing_display').addClass('pos-js_no_ADV');
- }
- $(window).on('resize', function () {
- $('.pos-customer_facing_display').toggleClass('pos-js_no_ADV', $('.pos-adv').hasClass('pos-hidden'));
- }).trigger('resize');
- };
- </script>
- </div>
- </t>
-
- <t t-name="CustomerFacingDisplayOrderLines">
- <t t-foreach="orderlines" t-as="orderline">
- <div class="pos_orderlines_item">
- <div><div t-attf-style="background-image:url(#{orderline.product.image_base64})"/></div>
- <div><t t-esc="orderline.product.display_name" /></div>
- <div><t t-esc="orderline.get_quantity_str()" /></div>
- <div><t t-esc="widget.format_currency(orderline.get_display_price())"/></div>
- </div>
- </t>
- </t>
-
- <t t-name="CustomerFacingDisplayPaymentLines">
- <t t-foreach="order.get_paymentlines()" t-as="paymentline">
- <div>
- <span><t t-esc="paymentline.name"/></span>
- </div>
- <div>
- <span><t t-esc="widget.format_currency(paymentline.get_amount())"/></span>
- </div>
- </t>
- <t t-if="order.get_paymentlines().length > 0">
- <div>
- <span class="pos-change_title">Change:</span>
- </div>
- <div >
- <span class="pos-change_amount"><t t-esc="widget.format_currency(order.get_change())"/></span>
- </div>
- </t>
- </t>
-</templates>
diff --git a/addons/point_of_sale/static/tests/tours/Chrome.tour.js b/addons/point_of_sale/static/tests/tours/Chrome.tour.js
new file mode 100644
index 00000000000..be45c8252ec
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/Chrome.tour.js
@@ -0,0 +1,74 @@
+odoo.define('point_of_sale.tour.Chrome', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ // Order 1 is at Product Screen
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.order('Desk Pad', '1', '2');
+
+ // Order 2 is at Payment Screen
+ Chrome.do.newOrder();
+ ProductScreen.exec.order('Monitor Stand', '3', '4');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.isShown();
+
+ // Order 3 is at Receipt Screen
+ Chrome.do.newOrder();
+ ProductScreen.exec.order('Whiteboard Pen', '5', '6');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+
+ // Select order 1, should be at Product Screen
+ Chrome.do.selectOrder('1');
+ ProductScreen.check.productIsDisplayed('Desk Pad');
+ ProductScreen.check.selectedOrderlineHas('Desk Pad', '1.0', '2.0');
+
+ // Select order 2, should be at Payment Screen
+ Chrome.do.selectOrder('2');
+ PaymentScreen.check.emptyPaymentlines('12.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+
+ // Select order 3, should be at Receipt Screen
+ Chrome.do.selectOrder('3');
+ ReceiptScreen.check.changeIs('0.0');
+
+ // Pay order 1, with change
+ Chrome.do.selectOrder('1');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('2 0');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.changeIs('18.0');
+
+ // Select order 3, should still be at Receipt Screen
+ // but change should be different.
+ Chrome.do.selectOrder('3');
+ ReceiptScreen.check.changeIs('0.0');
+
+ // click next screen on order 3
+ // then delete the new empty order
+ ReceiptScreen.do.clickNextOrder();
+ ProductScreen.check.orderIsEmpty();
+ Chrome.do.deleteOrder();
+
+ // Order 2 should be the current order
+ // Deleting it should open a popup, confirm it.
+ Chrome.do.deleteOrder();
+ Chrome.do.confirmPopup();
+
+ // Now left with order 1 in payment screen
+ // go next screen
+ ReceiptScreen.do.clickNextOrder();
+
+ Tour.register('ChromeTour', { test: true, url: '/pos/web' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js
new file mode 100644
index 00000000000..0253361323b
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js
@@ -0,0 +1,75 @@
+odoo.define('point_of_sale.tour.PaymentScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ ProductScreen.exec.order('Letter Tray', '10');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.emptyPaymentlines('52.8');
+
+ // Pay with cash, created line should have zero amount
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.check.selectedPaymentlineHas('Cash', '0.00');
+ PaymentScreen.do.pressNumpad('1 1');
+ PaymentScreen.check.selectedPaymentlineHas('Cash', '11.00');
+ PaymentScreen.check.remainingIs('41.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ // remove the selected paymentline with multiple backspace presses
+ PaymentScreen.do.pressNumpad('Backspace Backspace Backspace');
+ PaymentScreen.check.emptyPaymentlines('52.8');
+
+ // Pay with bank, the selected line should have full amount
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ // remove the line using the delete button
+ PaymentScreen.do.clickPaymentlineDelButton('Bank', '52.8');
+
+ // Use +10 and +50 to increment the amount of the paymentline
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('+10');
+ PaymentScreen.check.remainingIs('42.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.pressNumpad('+50');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('7.2');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickPaymentlineDelButton('Cash', '60.0');
+
+ // Multiple paymentlines
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('1');
+ PaymentScreen.check.remainingIs('51.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('5');
+ PaymentScreen.check.remainingIs('46.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.pressNumpad('2 0');
+ PaymentScreen.check.remainingIs('26.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+
+ // toggle email button
+ PaymentScreen.do.clickEmailButton();
+ PaymentScreen.check.emailButtonIsHighligted(true);
+ PaymentScreen.do.clickEmailButton();
+ PaymentScreen.check.emailButtonIsHighligted(false);
+
+ Tour.register('PaymentScreenTour', { test: true, url: '/pos/web' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js
new file mode 100644
index 00000000000..d2a253de924
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js
@@ -0,0 +1,99 @@
+odoo.define('point_of_sale.tour.ProductScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // Clicking product multiple times should increment quantity
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.10');
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '2.0', '10.20');
+
+ // Clicking product should add new orderline and select the orderline
+ // If orderline exists, increment the quantity
+ ProductScreen.do.clickDisplayedProduct('Letter Tray');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0', '4.80');
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3.0', '15.30');
+
+ // Check effects of clicking numpad buttons
+ ProductScreen.do.clickOrderline('Letter Tray', '1');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '0.0', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3', '15.30');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '0.0', '0.0');
+ ProductScreen.do.pressNumpad('1');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.1');
+ ProductScreen.do.pressNumpad('2');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '12.0', '61.2');
+ ProductScreen.do.pressNumpad('3');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.0', '627.3');
+ ProductScreen.do.pressNumpad('.');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.0', '627.3');
+ ProductScreen.do.pressNumpad('5 0');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '629.85');
+ ProductScreen.do.pressNumpad('Price');
+ ProductScreen.do.pressNumpad('1');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '123.5');
+ ProductScreen.do.pressNumpad('1 . 0');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,358.5');
+ ProductScreen.do.pressNumpad('Disc');
+ ProductScreen.do.pressNumpad('5 . 0');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,290.58');
+ ProductScreen.do.pressNumpad('Qty');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.orderIsEmpty();
+
+ // Check different subcategories
+ ProductScreen.do.clickSubcategory('Desks');
+ ProductScreen.check.productIsDisplayed('Desk Pad');
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickSubcategory('Miscellaneous');
+ ProductScreen.check.productIsDisplayed('Whiteboard Pen');
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickSubcategory('Chairs');
+ ProductScreen.check.productIsDisplayed('Letter Tray');
+ ProductScreen.do.clickHomeCategory();
+
+ // Add multiple orderlines then delete each of them until empty
+ ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
+ ProductScreen.do.clickDisplayedProduct('Wall Shelf Unit');
+ ProductScreen.do.clickDisplayedProduct('Small Shelf');
+ ProductScreen.do.clickDisplayedProduct('Magnetic Board');
+ ProductScreen.do.clickDisplayedProduct('Monitor Stand');
+ ProductScreen.do.clickOrderline('Whiteboard Pen', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.clickOrderline('Wall Shelf Unit', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.clickOrderline('Small Shelf', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Small Shelf', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.clickOrderline('Magnetic Board', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Magnetic Board', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ // Monitor Stand should be automatically selected at this point
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.orderIsEmpty();
+
+ Tour.register('ProductScreenTour', { test: true, url: '/pos/web' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js
new file mode 100644
index 00000000000..f6ee30bd405
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js
@@ -0,0 +1,35 @@
+odoo.define('point_of_sale.tour.ReceiptScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ const Tour = require('web_tour.tour');
+
+ startSteps();
+
+ // pay exact amount
+ ProductScreen.exec.order('Letter Tray', '10');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ ReceiptScreen.check.changeIs('0.00');
+ ReceiptScreen.do.clickNextOrder();
+
+ // pay more than total price
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.order('Desk Pad', '6', '5.0');
+ ProductScreen.exec.order('Whiteboard Pen', '6', '6.1');
+ ProductScreen.exec.order('Monitor Stand', '6', '1.3');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('8 0 0');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ ReceiptScreen.check.changeIs('725.6');
+ ReceiptScreen.do.clickNextOrder();
+
+ Tour.register('ReceiptScreenTour', { test: true, url: '/pos/web' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js
new file mode 100644
index 00000000000..8f3969906d8
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ newOrder() {
+ return [
+ {
+ content: 'new order',
+ trigger: '.order-selector .neworder-button',
+ },
+ ];
+ }
+ deleteOrder() {
+ return [
+ {
+ content: 'delete current order',
+ trigger: '.order-selector .deleteorder-button',
+ },
+ ];
+ }
+ selectOrder(orderSequence) {
+ return [
+ {
+ content: `select order '${orderSequence}'`,
+ trigger: `.order-selector .order-sequence:contains("${orderSequence}")`,
+ },
+ ];
+ }
+ confirmPopup() {
+ return [
+ {
+ content: 'confirm popup',
+ trigger: '.popups .modal-dialog .button.confirm',
+ },
+ ];
+ }
+ }
+
+ return { Do, Chrome: createTourMethods('Chrome', Do) };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js
new file mode 100644
index 00000000000..eab6b05725b
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js
@@ -0,0 +1,30 @@
+odoo.define('point_of_sale.tour.ErrorPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickConfirm() {
+ return [
+ {
+ content: 'click confirm button',
+ trigger: '.popup-error .footer .cancel',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'error popup is shown',
+ trigger: '.modal-dialog .popup-error',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return { Do, Check, ErrorPopup: createTourMethods('ErrorPopup', Do, Check) };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js
new file mode 100644
index 00000000000..e3d0a321814
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js
@@ -0,0 +1,69 @@
+odoo.define('point_of_sale.tour.NumberPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ /**
+ * @param {String} keys space-separated input keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '0 1 2 3 4 5 6 7 8 9 C'.split(' ');
+ const modeButtons = '+1 +10 +2 +20 +5 +50'.split(' ');
+ const decimalSeparators = ', .'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.popup-numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.popup-numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.popup-numpad .numpad-backspace`;
+ } else if (decimalSeparators.includes(key)) {
+ trigger = `.popup-numpad .number-char.dot`;
+ }
+ return {
+ content: `'${key}' pressed in numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+ clickConfirm() {
+ return [
+ {
+ content: 'click confirm button',
+ trigger: '.popup-number .footer .confirm',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'number popup is shown',
+ trigger: '.modal-dialog .popup-number',
+ run: () => {},
+ },
+ ];
+ }
+ inputShownIs(val) {
+ return [
+ {
+ content: 'number input element check',
+ trigger: '.modal-dialog .popup-number .popup-input',
+ run: () => {},
+ },
+ {
+ content: `input shown is '${val}'`,
+ trigger: `.modal-dialog .popup-number .popup-input:contains("${val}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return { Do, Check, NumberPopup: createTourMethods('NumberPopup', Do, Check) };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js
new file mode 100644
index 00000000000..07a4be28a7a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js
@@ -0,0 +1,195 @@
+odoo.define('point_of_sale.tour.PaymentScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickPaymentMethod(name) {
+ return [
+ {
+ content: `click '${name}' payment method`,
+ trigger: `.paymentmethods .button.paymentmethod:contains("${name}")`,
+ },
+ ];
+ }
+
+ /**
+ * Delete the paymentline having the given payment method name and amount.
+ * @param {String} name payment method
+ * @param {String} amount
+ */
+ clickPaymentlineDelButton(name, amount) {
+ return [
+ {
+ content: `delete ${name} paymentline with ${amount} amount`,
+ trigger: `.paymentlines .paymentline .col-name:contains("${name}") ~ .delete-button`,
+ },
+ ];
+ }
+
+ clickEmailButton() {
+ return [
+ {
+ content: `click email button`,
+ trigger: `.payment-buttons .js_email`,
+ },
+ ];
+ }
+
+ clickValidate() {
+ return [
+ {
+ content: 'validate payment',
+ trigger: `.payment-screen .button.next.highlight`,
+ },
+ ];
+ }
+
+ /**
+ * Press the numpad in sequence based on the given space-separated keys.
+ * @param {String} keys space-separated numpad keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '. +/- 0 1 2 3 4 5 6 7 8 9'.split(' ');
+ const modeButtons = '+10 +20 +50'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.payment-numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.payment-numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.payment-numpad .number-char img[alt="Backspace"]`;
+ }
+ return {
+ content: `'${key}' pressed in payment numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+
+ clickBack() {
+ return [
+ {
+ content: 'click back button',
+ trigger: '.payment-screen .button.back',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'payment screen is shown',
+ trigger: '.pos .payment-screen',
+ run: () => {},
+ },
+ ];
+ }
+ /**
+ * Check if change is the provided amount.
+ * @param {String} amount
+ */
+ changeIs(amount) {
+ return [
+ {
+ content: `change is ${amount}`,
+ trigger: `.payment-status-change .amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the remaining is the provided amount.
+ * @param {String} amount
+ */
+ remainingIs(amount) {
+ return [
+ {
+ content: `remaining amount is ${amount}`,
+ trigger: `.payment-status-remaining .amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ emailButtonIsHighligted(isHighlighted) {
+ return [
+ {
+ content: `check email button`,
+ trigger: isHighlighted
+ ? `.payment-buttons .js_email.highlight`
+ : `.payment-buttons .js_email:not(:has(.highlight))`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if validate button is highlighted.
+ * @param {Boolean} isHighlighted
+ */
+ validateButtonIsHighlighted(isHighlighted = true) {
+ return [
+ {
+ content: `validate button is ${
+ isHighlighted ? 'highlighted' : 'not highligted'
+ }`,
+ trigger: isHighlighted
+ ? `.payment-screen .button.next.highlight`
+ : `.payment-screen .button.next:not(:has(.highlight))`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the paymentlines are empty. Also provide the amount to pay.
+ * @param {String} amountToPay
+ */
+ emptyPaymentlines(amountToPay) {
+ return [
+ {
+ content: `there are no paymentlines`,
+ trigger: `.paymentlines-empty`,
+ run: () => {},
+ },
+ {
+ content: `amount to pay is '${amountToPay}'`,
+ trigger: `.paymentlines-empty .total:contains("${amountToPay}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the selected paymentline has the given payment method and amount.
+ * @param {String} paymentMethodName
+ * @param {String} amount
+ */
+ selectedPaymentlineHas(paymentMethodName, amount) {
+ return [
+ {
+ content: `line paid via '${paymentMethodName}' is selected`,
+ trigger: `.paymentlines .paymentline.selected .col-name:contains("${paymentMethodName}")`,
+ run: () => {},
+ },
+ {
+ content: `amount tendered in the line is '${amount}'`,
+ trigger: `.paymentlines .paymentline.selected .col-tendered:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return {
+ Do,
+ Check,
+ PaymentScreen: createTourMethods('PaymentScreen', Do, Check),
+ };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js
new file mode 100644
index 00000000000..5f2476cb1dd
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js
@@ -0,0 +1,177 @@
+odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickDisplayedProduct(name) {
+ return [
+ {
+ content: `click product '${name}'`,
+ trigger: `.product-list .product-name:contains("${name}")`,
+ },
+ ];
+ }
+
+ clickOrderline(name, quantity) {
+ return [
+ {
+ content: `selecting orderline with product '${name}' and quantity '${quantity}'`,
+ trigger: `.order .orderline:not(:has(.selected)) .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ },
+ {
+ content: `orderline with product '${name}' and quantity '${quantity}' has been selected`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickSubcategory(name) {
+ return [
+ {
+ content: `selecting '${name}' subcategory`,
+ trigger: `.category-list .category-simple-button:contains("${name}")`,
+ },
+ {
+ content: `'${name}' subcategory selected`,
+ trigger: `.breadcrumbs .breadcrumb-button:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickHomeCategory() {
+ return [
+ {
+ content: `click Home subcategory`,
+ trigger: `.breadcrumbs .breadcrumb-home`,
+ },
+ ];
+ }
+
+ /**
+ * Press the numpad in sequence based on the given space-separated keys.
+ * @param {String} keys space-separated numpad keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '. 0 1 2 3 4 5 6 7 8 9'.split(' ');
+ const modeButtons = 'Qty Price Disc'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.numpad .numpad-backspace`;
+ } else if (key === '+/-') {
+ trigger = `.numpad .numpad-minus`;
+ }
+ return {
+ content: `'${key}' pressed in product screen numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+
+ clickPayButton() {
+ return [
+ { content: 'click pay button', trigger: '.actionpad .button.pay' },
+ {
+ content: 'now in payment screen',
+ trigger: '.pos-content .payment-screen',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'product screen is shown',
+ trigger: '.product-screen:not(:has(.oe_hidden))',
+ run: () => {},
+ },
+ ];
+ }
+ selectedOrderlineHas(name, quantity, price) {
+ const res = [
+ {
+ // check first if the order widget is there and has orderlines
+ content: 'order widget has orderlines',
+ trigger: '.order .orderlines',
+ run: () => {},
+ },
+ {
+ content: `'${name}' is selected`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}")`,
+ run: function () {}, // it's a check
+ },
+ ];
+ if (quantity) {
+ res.push({
+ content: `selected line has ${quantity} quantity`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ run: function () {}, // it's a check
+ });
+ }
+ if (price) {
+ res.push({
+ content: `selected line has total price of ${price}`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .price:contains("${price}")`,
+ run: function () {}, // it's a check
+ });
+ }
+ return res;
+ }
+ orderIsEmpty() {
+ return [
+ {
+ content: `order is empty`,
+ trigger: `.order .order-empty`,
+ run: () => {},
+ },
+ ];
+ }
+
+ productIsDisplayed(name) {
+ return [
+ {
+ content: `'${name}' should be displayed`,
+ trigger: `.product-list .product-name:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ order(productName, quantity, price) {
+ const res = this._do.clickDisplayedProduct(productName);
+ if (price) {
+ res.push(...this._do.pressNumpad('Price'));
+ res.push(...this._do.pressNumpad(price.toString().split('').join(' ')));
+ res.push(...this._do.pressNumpad('Qty'));
+ }
+ for (let char of quantity.toString()) {
+ if ('.0123456789'.includes(char)) {
+ res.push(...this._do.pressNumpad(char));
+ } else if ('-'.includes(char)) {
+ res.push(...this._do.pressNumpad('+/-'));
+ }
+ }
+ return res;
+ }
+ }
+
+ return {
+ Do,
+ Check,
+ Execute,
+ ProductScreen: createTourMethods('ProductScreen', Do, Check, Execute),
+ };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js
new file mode 100644
index 00000000000..6000af6a962
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js
@@ -0,0 +1,59 @@
+odoo.define('point_of_sale.tour.ReceiptScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickNextOrder() {
+ return [
+ {
+ content: 'go to next screen',
+ trigger: '.receipt-screen .button.next.highlight',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'receipt screen is shown',
+ trigger: '.pos .receipt-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ receiptIsThere() {
+ return [
+ {
+ content: 'there should be the receipt',
+ trigger: '.receipt-screen .pos-receipt',
+ run: () => {},
+ },
+ ];
+ }
+
+ changeIs(amount) {
+ return [
+ {
+ content: `check if change field is visible`,
+ trigger: `.receipt-screen .change-value`,
+ run: () => {},
+ },
+ {
+ content: `change amount should be ${amount}`,
+ trigger: `.receipt-screen .change-value:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return {
+ Do,
+ Check,
+ ReceiptScreen: createTourMethods('ReceiptScreen', Do, Check),
+ };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js
new file mode 100644
index 00000000000..c13971a17f3
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.tour.SelectionPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickItem(name) {
+ return [
+ {
+ content: `click selection '${name}'`,
+ trigger: `.selection-item:contains("${name}")`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ hasSelectionItem(name) {
+ return [
+ {
+ content: `selection popup has '${name}'`,
+ trigger: `.selection-item:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ isShown() {
+ return [
+ {
+ content: 'selection popup is shown',
+ trigger: '.modal-dialog .popup-selection',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return { Do, Check, SelectionPopup: createTourMethods('SelectionPopup', Do, Check) };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/utils.js b/addons/point_of_sale/static/tests/tours/helpers/utils.js
new file mode 100644
index 00000000000..b76c2677e35
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/utils.js
@@ -0,0 +1,144 @@
+odoo.define('point_of_sale.tour.utils', function (require) {
+ 'use strict';
+
+ const config = require('web.config');
+
+ /**
+ * USAGE
+ * -----
+ *
+ * ```
+ * const { startSteps, getSteps, createTourMethods } = require('point_of_sale.utils');
+ * const { Other } = require('point_of_sale.tour.OtherMethods');
+ *
+ * // 1. Define classes Do, Check and Execute having methods that
+ * // each return array of tour steps.
+ * class Do {
+ * click() {
+ * return [{ content: 'click button', trigger: '.button' }];
+ * }
+ * }
+ * class Check {
+ * isHighligted() {
+ * return [{ content: 'button is highlighted', trigger: '.button.highlight', run: () => {} }];
+ * }
+ * }
+ * // Notice that Execute has access to methods defined in Do and Check classes
+ * // Also, we can compose steps from other module.
+ * class Execute {
+ * complexSteps() {
+ * return [...this._do.click(), ...this._check.isHighlighted(), ...Other._exec.complicatedSteps()];
+ * }
+ * }
+ *
+ * // 2. Instantiate these class definitions using `createTourMethods`.
+ * // The returned object gives access to the defined methods above
+ * // thru the do, check and exec properties.
+ * // - do gives access to the methods defined in Do class
+ * // - check gives access to the methods defined in Check class
+ * // - exec gives access to the methods defined in Execute class
+ * const Screen = createTourMethods('Screen', Do, Check, Execute);
+ *
+ * // 3. Call `startSteps` to start empty steps.
+ * startSteps();
+ *
+ * // 4. Call the tour methods to populate the steps created by `startSteps`.
+ * Screen.do.click(); // return of this method call is added to steps created by startSteps
+ * Screen.check.isHighlighted() // same as above
+ * Screen.exec.complexSteps() // same as above
+ *
+ * // 5. Call `getSteps` which returns the generated tour steps.
+ * const steps = getSteps();
+ * ```
+ */
+ let steps = [];
+
+ function startSteps() {
+ // always start by waiting for loading to finish
+ steps = [
+ {
+ content: 'wait for loading to finish',
+ trigger: 'body:not(:has(.loader))',
+ run: function () {},
+ },
+ ];
+ }
+
+ function getSteps() {
+ return steps;
+ }
+
+ // this is the method decorator
+ // when the method is called, the generated steps are added
+ // to steps
+ const methodProxyHandler = {
+ apply(target, thisArg, args) {
+ const res = target.call(thisArg, ...args);
+ if (config.isDebug()) {
+ // This step is added before the real steps.
+ // Very useful when debugging because we know which
+ // method call failed and what were the parameters.
+ const constructor = thisArg.constructor.name.split(' ')[1];
+ const methodName = target.name.split(' ')[1];
+ const argList = args
+ .map((a) => (typeof a === 'string' ? `'${a}'` : `${a}`))
+ .join(', ');
+ steps.push({
+ content: `DOING "${constructor}.${methodName}(${argList})"`,
+ trigger: '.pos',
+ run: () => {},
+ });
+ }
+ steps.push(...res);
+ return res;
+ },
+ };
+
+ // we proxy get of the method to decorate the method call
+ const proxyHandler = {
+ get(target, key) {
+ return new Proxy(target[key].bind(target), methodProxyHandler);
+ },
+ };
+
+ /**
+ * Creates an object with `do`, `check` and `exec` properties which are instances of
+ * the given `Do`, `Check` and `Execute` classes, respectively. Calling methods
+ * automatically adds the returned steps to the steps created by `startSteps`.
+ *
+ * There are however underscored version (_do, _check, _exec).
+ * Calling methods thru the underscored version does not automatically
+ * add the returned steps to the current steps array. Useful when composing
+ * steps from other methods.
+ *
+ * @param {String} name
+ * @param {Function} Do class containing methods which return array of tour steps
+ * @param {Function} Check similar to Do class but the steps are mainly for checking
+ * @param {Function} Execute class containing methods which return array of tour steps
+ * but has access to methods of Do and Check classes via .do and .check,
+ * respectively. Here, we define methods that return tour steps based
+ * on the combination of steps from Do and Check.
+ */
+ function createTourMethods(name, Do, Check = class {}, Execute = class {}) {
+ Object.defineProperty(Do, 'name', { value: `${name}.do` });
+ Object.defineProperty(Check, 'name', { value: `${name}.check` });
+ Object.defineProperty(Execute, 'name', {
+ value: `${name}.exec`,
+ });
+ const methods = { do: new Do(), check: new Check(), exec: new Execute() };
+ // Allow Execute to have access to methods defined in Do and Check
+ // via do and exec, respectively.
+ methods.exec._do = methods.do;
+ methods.exec._check = methods.check;
+ return {
+ do: new Proxy(methods.do, proxyHandler),
+ check: new Proxy(methods.check, proxyHandler),
+ exec: new Proxy(methods.exec, proxyHandler),
+ _do: methods.do,
+ _check: methods.check,
+ _exec: methods.exec,
+ };
+ }
+
+ return { startSteps, getSteps, createTourMethods };
+});
diff --git a/addons/point_of_sale/static/tests/tours/point_of_sale.js b/addons/point_of_sale/static/tests/tours/point_of_sale.js
index fa1b55cd0e3..72759fac829 100644
--- a/addons/point_of_sale/static/tests/tours/point_of_sale.js
+++ b/addons/point_of_sale/static/tests/tours/point_of_sale.js
@@ -46,7 +46,7 @@ odoo.define('point_of_sale.tour.pricelist', function (require) {
var steps = [{ // Leave category displayed by default
content: 'waiting for loading to finish',
- trigger: 'body:has(.loader:hidden)',
+ trigger: 'body:not(:has(.loader))',
run: function () {
var product_wall_shelf = posmodel.db.search_product_in_category(0, 'Wall Shelf Unit')[0];
var product_small_shelf = posmodel.db.search_product_in_category(0, 'Small Shelf')[0];
@@ -93,7 +93,7 @@ odoo.define('point_of_sale.tour.pricelist', function (require) {
run: function () {}, // it's a check
}, {
content: "click category switch",
- trigger: ".js-category-switch",
+ trigger: ".breadcrumb-home",
run: 'click',
}, {
content: "click pricelist button",
@@ -258,7 +258,11 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
trigger: keypad_selector + ' .input-button:contains("' + current_char + '"):visible'
});
}
-
+ steps.push({
+ content: 'do nothing check',
+ trigger: '.pos',
+ run: function () {},
+ })
return steps;
}
@@ -313,38 +317,38 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
// sending email should be checked in different test
content: "deactivate email",
trigger: '.button.js_email',
+ }, {
+ content: "email button should not be highlighted after deactivating",
+ trigger: '.button.js_email:not(.highlight)',
+ run: function() {},
}];
}
function finish_order() {
return [{
content: "validate the order",
- trigger: '.button.next:visible',
- }, {
- content: "verify that the order is being sent to the backend",
- trigger: ".js_connecting:visible",
- run: function () {}, // it's a check
+ trigger: '.payment-screen .button.next.highlight:visible',
}, {
content: "verify that the order has been successfully sent to the backend",
trigger: ".js_connected:visible",
run: function () {}, // it's a check
}, {
- content: "next order",
- trigger: '.button.next:visible',
- }, { // Leave category displayed by default
- content: "click category switch",
- trigger: ".js-category-switch",
- run: 'click',
+ content: "click Next Order",
+ trigger: '.receipt-screen .button.next.highlight:visible',
+ }, {
+ content: "check if we left the receipt screen",
+ trigger: '.pos-content .screen:not(:has(.receipt-screen))',
+ run: function () {},
}];
}
var steps = [{
content: 'waiting for loading to finish',
- trigger: 'body:has(.loader:hidden)',
+ trigger: 'body:not(:has(.loader))',
run: function () {}, // it's a check
}, { // Leave category displayed by default
content: "click category switch",
- trigger: ".js-category-switch",
+ trigger: ".breadcrumb-home",
run: 'click',
}];
@@ -420,7 +424,7 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
trigger: ".header-button",
}, {
content: "confirm closing the frontend",
- trigger: ".header-button",
+ trigger: ".header-button.confirm",
run: function() {}, //it's a check,
}]);
diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_env.js b/addons/point_of_sale/static/tests/unit/helpers/test_env.js
new file mode 100644
index 00000000000..81ce9ce3ac4
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/helpers/test_env.js
@@ -0,0 +1,43 @@
+odoo.define('point_of_sale.test_env', async function (require) {
+ 'use strict';
+
+ /**
+ * Many components in PoS are dependent on the PosModel instance (pos).
+ * Therefore, for unit tests that require pos in the Components' env, we
+ * prepared here a test env maker (makePosTestEnv) based on
+ * makeTestEnvironment of web.
+ */
+
+ const makeTestEnvironment = require('web.test_env');
+ const env = require('web.env');
+ const models = require('point_of_sale.models');
+
+ await env.session.is_bound;
+ const pos = new models.PosModel({
+ rpc: env.services.rpc,
+ session: env.session,
+ do_action: async () => {},
+ setLoadingMessage: () => {},
+ setLoadingProgress: () => {},
+ showLoadingSkip: () => {},
+ });
+ await pos.ready;
+
+ /**
+ * @param {Object} env default env
+ * @param {Function} providedRPC mock rpc
+ * @param {Function} providedDoAction mock do_action
+ */
+ function makePosTestEnv(env = {}, providedRPC = null, providedDoAction = null) {
+ env = Object.assign(env, { pos });
+ let posEnv = makeTestEnvironment(env, providedRPC);
+ // Replace rpc in the PosModel instance after loading
+ // data from the server so that every succeeding rpc calls
+ // made by pos are mocked by the providedRPC.
+ pos.rpc = posEnv.rpc;
+ pos.do_action = providedDoAction;
+ return posEnv;
+ }
+
+ return makePosTestEnv;
+});
diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_main.js b/addons/point_of_sale/static/tests/unit/helpers/test_main.js
new file mode 100644
index 00000000000..a5473389092
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/helpers/test_main.js
@@ -0,0 +1,6 @@
+odoo.define('web.web_client', function (require) {
+ // this module is required by the test
+ const WebClient = require('web.AbstractWebClient');
+ const webClient = new WebClient();
+ return webClient;
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js
new file mode 100644
index 00000000000..9570aa0b7e8
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js
@@ -0,0 +1,135 @@
+odoo.define('point_of_sale.tests.ChromeWidgets', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+
+ QUnit.module('unit tests for Chrome Widgets', {});
+
+ QUnit.test('CashierName', async function (assert) {
+ assert.expect(1);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><CashierName></CashierName></div>
+ `;
+ Parent.env.pos.employee.name = 'Test Employee';
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ assert.strictEqual(parent.el.querySelector('span.username').innerText, 'Test Employee');
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('HeaderButton', async function (assert) {
+ assert.expect(1);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><HeaderButton></HeaderButton></div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const headerButton = parent.el.querySelector('.header-button');
+ await testUtils.dom.click(headerButton);
+ await testUtils.nextTick();
+ assert.ok(headerButton.classList.contains('confirm'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('OrderSelector', async function (assert) {
+ assert.expect(4);
+
+ class Parent extends PopupControllerMixin(PosComponent) {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <OrderSelector></OrderSelector>
+ <t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const plusButton = parent.el.querySelector('.neworder-button');
+ const minusButton = parent.el.querySelector('.deleteorder-button');
+
+ await testUtils.dom.click(plusButton);
+ await testUtils.nextTick();
+ assert.strictEqual(2, parent.el.querySelectorAll('.order-sequence').length);
+ await testUtils.dom.click(minusButton);
+ await testUtils.nextTick();
+ assert.strictEqual(1, parent.el.querySelectorAll('.order-sequence').length);
+
+ const product = Object.values(pos.db.product_by_id)[0];
+ pos.get_order().add_product(product);
+
+ // try deleting the order with orderline
+ await testUtils.dom.click(minusButton);
+ await testUtils.nextTick();
+
+ // confirm popup should appear
+ assert.ok(parent.el.querySelector('.popup'));
+ // confirm deletion
+ await testUtils.dom.click(parent.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ // there should be new order created
+ assert.strictEqual(1, parent.el.querySelectorAll('.order-sequence').length);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('SyncNotification', async function (assert) {
+ assert.expect(5);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <SyncNotification></SyncNotification>
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+ pos.set('synch', { status: 'connected', pending: false });
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connected'));
+
+ pos.set('synch', { status: 'connecting', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connecting'));
+
+ pos.set('synch', { status: 'disconnected', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_disconnected'));
+
+ pos.set('synch', { status: 'error', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_error'));
+
+ pos.set('synch', { status: 'error', pending: 10 });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.js_msg').innerText.includes('10'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js
new file mode 100644
index 00000000000..4b2217cb1ec
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js
@@ -0,0 +1,414 @@
+odoo.define('point_of_sale.tests.ComponentRegistry', function(require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+
+ QUnit.module('unit tests for ComponentRegistry', {
+ before() {},
+ });
+
+ QUnit.test('basic extend', async function(assert) {
+ assert.expect(5);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ let a = new RegA();
+ assert.verifySteps(['A', 'A1']);
+ assert.ok(a instanceof RegA);
+ assert.ok(RegA.name === 'A');
+ });
+
+ QUnit.test('addByExtending', async function(assert) {
+ assert.expect(8);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let A2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A2');
+ }
+ };
+ Registries.Component.extend(A, A2);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ const RegB = Registries.Component.get(B);
+ let b = new RegB();
+ assert.verifySteps(['A', 'A1', 'A2', 'B']);
+ assert.ok(b instanceof RegA);
+ assert.ok(b instanceof RegB);
+ assert.ok(RegB.name === 'B');
+ });
+
+ QUnit.test('extend the one that is added by extending', async function(assert) {
+ assert.expect(6);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let B1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B1');
+ }
+ };
+ Registries.Component.extend(B, B1);
+
+ let B2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B2');
+ }
+ };
+ Registries.Component.extend(B, B2);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ const RegB = Registries.Component.get(B);
+ new RegB();
+ assert.verifySteps(['A', 'A1', 'B', 'B1', 'B2']);
+ });
+
+ QUnit.test('addByExtending based on added by extending', async function(assert) {
+ assert.expect(10);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let C = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('C');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ let B7 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B7');
+ }
+ };
+ Registries.Component.extend(B, B7);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ const RegB = Registries.Component.get(B);
+ const RegC = Registries.Component.get(C);
+ let c = new RegC();
+ assert.verifySteps(['A', 'A1', 'B', 'B7', 'C']);
+ assert.ok(c instanceof RegA);
+ assert.ok(c instanceof RegB);
+ assert.ok(c instanceof RegC);
+ assert.ok(RegC.name === 'C');
+ });
+
+ QUnit.test('deeper inheritance', async function(assert) {
+ assert.expect(9);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let C = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('C');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ let B2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B2');
+ }
+ };
+ Registries.Component.extend(B, B2);
+
+ let B3 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B3');
+ }
+ };
+ Registries.Component.extend(B, B3);
+
+ let A9 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A9');
+ }
+ };
+ Registries.Component.extend(A, A9);
+
+ let E = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('E');
+ }
+ };
+ Registries.Component.addByExtending(E, C);
+
+ Registries.Component.freeze();
+
+ // |A| => A9 -> A1 -> A
+ // |B| => B3 -> B2 -> B -> |A|
+ // |C| => C -> |B|
+ // |E| => E -> |C|
+
+ new (Registries.Component.get(E))();
+ assert.verifySteps(['A', 'A1', 'A9', 'B', 'B2', 'B3', 'C', 'E']);
+ });
+
+ QUnit.test('mixins?', async function(assert) {
+ assert.expect(12);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let Mixin = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('Mixin');
+ }
+ mixinMethod() {
+ return 'mixinMethod';
+ }
+ get mixinGetter() {
+ return 'mixinGetter';
+ }
+ };
+
+ // use the mixin when declaring B.
+ let B = x =>
+ class extends Mixin(x) {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ B = Registries.Component.get(B);
+ const b = new B();
+ assert.verifySteps(['A', 'A1', 'Mixin', 'B']);
+ // instance of B should have the mixin properties
+ assert.strictEqual(b.mixinMethod(), 'mixinMethod');
+ assert.strictEqual(b.mixinGetter, 'mixinGetter');
+
+ // instance of A should not have the mixin properties
+ A = Registries.Component.get(A);
+ const a = new A();
+ assert.verifySteps(['A', 'A1']);
+ assert.notOk(a.mixinMethod);
+ assert.notOk(a.mixinGetter);
+ });
+
+ QUnit.test('extending methods', async function(assert) {
+ assert.expect(16);
+
+ class A {
+ foo() {
+ assert.step('A foo');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ bar() {
+ assert.step('B bar');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ bar() {
+ assert.step('A1 bar');
+ // should only be for A.
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let B1 = x =>
+ class extends x {
+ foo() {
+ super.foo();
+ assert.step('B1 foo');
+ }
+ };
+ Registries.Component.extend(B, B1);
+
+ let C = x =>
+ class extends x {
+ foo() {
+ super.foo();
+ assert.step('C foo');
+ }
+ bar() {
+ super.bar();
+ assert.step('C bar');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ Registries.Component.freeze();
+
+ A = Registries.Component.get(A);
+ B = Registries.Component.get(B);
+ C = Registries.Component.get(C);
+ const a = new A();
+ const b = new B();
+ const c = new C();
+
+ a.foo();
+ assert.verifySteps(['A foo']);
+ b.foo();
+ assert.verifySteps(['A foo', 'B1 foo']);
+ c.foo();
+ assert.verifySteps(['A foo', 'B1 foo', 'C foo']);
+
+ a.bar();
+ assert.verifySteps(['A1 bar']);
+ b.bar();
+ assert.verifySteps(['B bar']);
+ c.bar();
+ assert.verifySteps(['B bar', 'C bar']);
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js
new file mode 100644
index 00000000000..754ade22d70
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js
@@ -0,0 +1,310 @@
+odoo.define('point_of_sale.tests.PaymentScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useListener } = require('web.custom_hooks');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+ const { useState } = owl;
+
+ QUnit.module('unit tests for PaymentScreen components', {});
+
+ QUnit.test('PaymentMethodButton', async function (assert) {
+ assert.expect(2);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('new-payment-line', this._newPaymentLine);
+ }
+ _newPaymentLine() {
+ assert.step('new-payment-line');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentMethodButton paymentMethod="{ name: 'Cash', id: 1 }" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const button = parent.el.querySelector('.paymentmethod');
+ await testUtils.dom.click(button);
+ assert.verifySteps(['new-payment-line']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PSNumpadInputButton', async function (assert) {
+ assert.expect(15);
+
+ class Parent extends PosComponent {
+ constructor({ value, text, changeClassTo }) {
+ super();
+ this.state = useState({ value, text, changeClassTo });
+ useListener('input-from-numpad', this._inputFromNumpad);
+ }
+ _inputFromNumpad({ detail: { key } }) {
+ assert.step(`${key}-input`);
+ }
+ setState(obj) {
+ Object.assign(this.state, obj);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo" />
+ </div>
+ `;
+
+ let parent = new Parent({ value: '1' });
+ await parent.mount(testUtils.prepareTarget());
+
+ let button = parent.el.querySelector('button');
+ assert.ok(button.textContent.includes('1'));
+ assert.ok(button.classList.contains('number-char'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['1-input']);
+
+ parent.setState({ value: '2', text: 'Two' });
+ await testUtils.nextTick();
+ assert.ok(button.textContent.includes('Two'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['2-input']);
+
+ parent.setState({ value: '+12', text: null, changeClassTo: 'not-number-char' });
+ await testUtils.nextTick();
+ assert.ok(button.textContent.includes('+12'));
+ assert.ok(button.classList.contains('not-number-char'));
+ // class number-char should have been replaced
+ assert.notOk(button.classList.contains('number-char'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['+12-input']);
+
+ parent.unmount();
+ parent.destroy();
+
+ // using the slot should ignore value and text props of the component
+ Parent.template = xml/* html */ `
+ <div>
+ <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo">
+ <span>UseSlot</span>
+ </PSNumpadInputButton>
+ </div>
+ `;
+ parent = new Parent({ value: 'slotted', text: 'Text' });
+ await parent.mount(testUtils.prepareTarget());
+
+ button = parent.el.querySelector('button');
+ assert.ok(button.textContent.includes('UseSlot'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['slotted-input']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PaymentScreenPaymentLines', async function (assert) {
+ assert.expect(15);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ useListener('delete-payment-line', this._onDeletePaymentLine);
+ useListener('select-payment-line', this._onSelectPaymentLine);
+ }
+ get paymentLines() {
+ return this.order.get_paymentlines();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ mounted() {
+ this.order.paymentlines.on('change', this.render, this);
+ }
+ willUnmount() {
+ this.order.paymentlines.off('change', null, this);
+ }
+ _onDeletePaymentLine() {
+ assert.step('delete-click');
+ }
+ _onSelectPaymentLine() {
+ assert.step('select-click');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentScreenPaymentLines paymentLines="paymentLines" />
+ </div>
+ `;
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const order = parent.env.pos.get_order();
+ const cashPM = { id: 0, name: 'Cash', is_cash_count: true, use_payment_terminal: false };
+ const bankPM = { id: 0, name: 'Bank', is_cash_count: false, use_payment_terminal: false };
+
+ assert.ok(parent.el.querySelector('.paymentlines-empty'), 'empty payment lines');
+
+ let paymentline1 = order.add_paymentline(cashPM);
+ await testUtils.nextTick();
+
+ assert.notOk(parent.el.querySelector('.paymentlines-empty'), 'not empty anymore');
+ let statusContainer = parent.el.querySelector('.payment-status-container');
+ let linesEl = parent.el.querySelector('.paymentlines');
+ assert.ok(statusContainer, 'payment status is shown');
+ assert.ok(linesEl, 'payment lines are shown');
+ let newLine = linesEl.querySelector('.selected');
+ assert.ok(newLine, 'the new line is automatically selected');
+
+ let paymentline2 = order.add_paymentline(bankPM);
+ await testUtils.nextTick();
+ assert.notOk(
+ linesEl.querySelector('.selected') === newLine,
+ 'the previously added paymentline should not be selected anymore'
+ );
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline:not(.heading)').length === 2,
+ 'there should be two paymentlines'
+ );
+
+ let paymentline3 = order.add_paymentline(cashPM);
+ await testUtils.nextTick();
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline:not(.heading)').length === 3,
+ 'there should be three paymentlines'
+ );
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline.selected').length === 1,
+ 'there should only be one selected paymentline'
+ );
+
+ await testUtils.dom.click(linesEl.querySelector('.paymentline.selected .delete-button'));
+ await testUtils.nextTick();
+ assert.verifySteps(['delete-click', 'select-click']);
+
+ // click the 2nd payment line
+ await testUtils.dom.click(linesEl.querySelectorAll('.paymentline:not(.heading)')[1]);
+ await testUtils.nextTick();
+ assert.verifySteps(['select-click']);
+
+ // remove paymentline3 (the selected)
+ order.remove_paymentline(paymentline3);
+ await testUtils.nextTick();
+ assert.notOk(
+ linesEl.querySelector('.paymentline.selected'),
+ 'no more selected payment line'
+ );
+
+ order.remove_paymentline(paymentline1);
+ order.remove_paymentline(paymentline2);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PaymentScreenElectronicPayment', async function (assert) {
+ assert.expect(17);
+
+ class SimulatedPaymentLine extends Backbone.Model {
+ constructor() {
+ super();
+ this.payment_status = 'pending';
+ this.can_be_reversed = false;
+ }
+ setPaymentStatus(status) {
+ this.payment_status = status;
+ this.trigger('change');
+ }
+ toggleCanBeReversed() {
+ this.can_be_reversed = !this.can_be_reversed;
+ this.trigger('change');
+ }
+ }
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ this.line = new SimulatedPaymentLine();
+ useListener('send-payment-request', () => assert.step('send-payment-request'));
+ useListener('send-force-done', () => assert.step('send-force-done'));
+ useListener('send-payment-cancel', () => assert.step('send-payment-cancel'));
+ useListener('send-payment-reverse', () => assert.step('send-payment-reverse'));
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentScreenElectronicPayment line="line" />
+ </div>
+ `;
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ assert.ok(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-request']);
+
+ parent.line.setPaymentStatus('retry');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-request']);
+
+ parent.line.setPaymentStatus('force_done');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_force_done'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-force-done']);
+
+ parent.line.setPaymentStatus('waitingCard');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_cancel'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-cancel']);
+
+ parent.line.setPaymentStatus('waiting');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('waitingCancel');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('reversing');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('done');
+ await testUtils.nextTick();
+ assert.notOk(parent.el.querySelector('.paymentline .send_payment_reversal'));
+
+ parent.line.toggleCanBeReversed();
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline .send_payment_reversal'));
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_reversal'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-reverse']);
+
+ parent.line.setPaymentStatus('reversed');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ProductScreen.js b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js
new file mode 100644
index 00000000000..afdebb9d1cd
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js
@@ -0,0 +1,594 @@
+odoo.define('point_of_sale.tests.ProductScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+ const { useState } = owl;
+
+ QUnit.module('unit tests for ProductScreen components', {});
+
+ QUnit.test('ActionpadWidget', async function (assert) {
+ assert.expect(7);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ useListener('click-customer', () => assert.step('click-customer'));
+ useListener('click-pay', () => assert.step('click-pay'));
+ this.state = useState({ client: null });
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ActionpadWidget client="state.client" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const setCustomerButton = parent.el.querySelector('button.set-customer');
+ const payButton = parent.el.querySelector('button.pay');
+
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.innerText.includes('Customer'));
+
+ // change to customer with short name
+ parent.state.client = { name: 'Test' };
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.innerText.includes('Test'));
+
+ // change to customer with long name
+ parent.state.client = { name: 'Change Customer' };
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.classList.contains('decentered'));
+
+ parent.state.client = null;
+
+ // click set-customer button
+ await testUtils.dom.click(setCustomerButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['click-customer']);
+
+ // click pay button
+ await testUtils.dom.click(payButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['click-pay']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('NumpadWidget', async function (assert) {
+ assert.expect(23);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('set-numpad-mode', this.setNumpadMode);
+ useListener('numpad-click-input', this.numpadClickInput);
+ }
+ setNumpadMode({ detail: { mode } }) {
+ assert.step(mode);
+ }
+ numpadClickInput({ detail: { key } }) {
+ assert.step(key);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><NumpadWidget></NumpadWidget></div>
+ `;
+
+ const pos = Parent.env.pos;
+ // set this old values back after testing
+ const old_config = pos.config;
+ const old_cashier = pos.get('cashier');
+
+ // set dummy values in pos.config and pos.get('cashier')
+ pos.config = {
+ restrict_price_control: false,
+ };
+ pos.set('cashier', { role: 'manager' });
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const modeButtons = parent.el.querySelectorAll('.mode-button');
+ let qtyButton, discButton, priceButton;
+ for (let button of modeButtons) {
+ if (button.textContent.includes('Qty')) {
+ qtyButton = button;
+ }
+ if (button.textContent.includes('Disc')) {
+ discButton = button;
+ }
+ if (button.textContent.includes('Price')) {
+ priceButton = button;
+ }
+ }
+
+ // initially, qty button is active
+ assert.ok(qtyButton.classList.contains('selected-mode'));
+ assert.ok(!discButton.classList.contains('selected-mode'));
+ assert.ok(!priceButton.classList.contains('selected-mode'));
+
+ await testUtils.dom.click(discButton);
+ await testUtils.nextTick();
+ assert.ok(!qtyButton.classList.contains('selected-mode'));
+ assert.ok(discButton.classList.contains('selected-mode'));
+ assert.ok(!priceButton.classList.contains('selected-mode'));
+ assert.verifySteps(['discount']);
+
+ await testUtils.dom.click(priceButton);
+ await testUtils.nextTick();
+ assert.ok(!qtyButton.classList.contains('selected-mode'));
+ assert.ok(!discButton.classList.contains('selected-mode'));
+ assert.ok(priceButton.classList.contains('selected-mode'));
+ assert.verifySteps(['price']);
+
+ const numpadOne = [...parent.el.querySelectorAll('.number-char').values()].find((el) =>
+ el.textContent.includes('1')
+ );
+ const numpadMinus = parent.el.querySelector('.numpad-minus');
+ const numpadBackspace = parent.el.querySelector('.numpad-backspace');
+
+ await testUtils.dom.click(numpadOne);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+
+ await testUtils.dom.click(numpadMinus);
+ await testUtils.nextTick();
+ assert.verifySteps(['-']);
+
+ await testUtils.dom.click(numpadBackspace);
+ await testUtils.nextTick();
+ assert.verifySteps(['Backspace']);
+
+ await testUtils.dom.click(priceButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['price']);
+
+ // change to price control restriction and the cashier is not manager
+ pos.config.restrict_price_control = true;
+ pos.set('cashier', { role: 'not manager' });
+ await testUtils.nextTick();
+
+ assert.ok(priceButton.classList.contains('disabled-mode'));
+ assert.ok(qtyButton.classList.contains('selected-mode'));
+
+ // reset old config and cashier values to pos
+ pos.config = old_config;
+ pos.set('cashier', old_cashier);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('ProductsWidgetControlPanel', async function (assert) {
+ assert.expect(32);
+
+ // This test incorporates the following components:
+ // CategoryBreadcrumb
+ // CategoryButton
+ // CategorySimpleButton
+ // HomeCategoryBreadcrumb
+
+ // Create dummy category data
+ //
+ // Root
+ // | Test1
+ // | | Test2
+ // | ` Test3
+ // | | Test5
+ // | ` Test6
+ // ` Test4
+
+ const rootCategory = { id: 0, name: 'Root', parent: null };
+ const testCategory1 = { id: 1, name: 'Test1', parent: 0 };
+ const testCategory2 = { id: 2, name: 'Test2', parent: 1 };
+ const testCategory3 = { id: 3, name: 'Test3', parent: 1 };
+ const testCategory4 = { id: 4, name: 'Test4', parent: 0 };
+ const testCategory5 = { id: 5, name: 'Test5', parent: 3 };
+ const testCategory6 = { id: 6, name: 'Test6', parent: 3 };
+ const categories = {
+ 0: rootCategory,
+ 1: testCategory1,
+ 2: testCategory2,
+ 3: testCategory3,
+ 4: testCategory4,
+ 5: testCategory5,
+ 6: testCategory6,
+ };
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ selectedCategoryId: 0 });
+ useListener('switch-category', this.switchCategory);
+ useListener('update-search', this.updateSearch);
+ useListener('clear-search', this.clearSearch);
+ }
+ get breadcrumbs() {
+ if (this.state.selectedCategoryId === 0) return [];
+ let current = categories[this.state.selectedCategoryId];
+ const res = [current];
+ while (current.parent != 0) {
+ const toAdd = categories[current.parent];
+ res.push(toAdd);
+ current = toAdd;
+ }
+ return res.reverse();
+ }
+ get subcategories() {
+ return Object.values(categories).filter(
+ ({ parent }) => parent == this.state.selectedCategoryId
+ );
+ }
+ switchCategory({ detail: id }) {
+ this.state.selectedCategoryId = id;
+ assert.step(`${id}`);
+ }
+ updateSearch(event) {
+ assert.step(event.detail);
+ }
+ clearSearch() {
+ assert.step('cleared');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" />
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+ const old_config = pos.config;
+ // set dummy config
+ pos.config = { iface_display_categ_images: false };
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // The following tests the breadcrumbs and subcategory buttons
+
+ // check if HomeCategoryBreadcrumb is rendered
+ assert.ok(
+ parent.el.querySelector('.breadcrumb-home'),
+ 'Home category should always be there'
+ );
+ let subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test1')));
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test4')));
+
+ // click Test1
+ let test1Span = subcategorySpans.find((span) => span.textContent.includes('Test1'));
+ await testUtils.dom.click(test1Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+ assert.ok(
+ [...parent.el.querySelectorAll('.breadcrumb-button')][1].textContent.includes('Test1')
+ );
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test2')));
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test3')));
+
+ // click Test2
+ let test2Span = subcategorySpans.find((span) => span.textContent.includes('Test2'));
+ await testUtils.dom.click(test2Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['2']);
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 0, 'Test2 should not have subcategories');
+
+ // go back to Test1
+ let breadcrumb1 = [...parent.el.querySelectorAll('.breadcrumb-button')].find((el) =>
+ el.textContent.includes('Test1')
+ );
+ await testUtils.dom.click(breadcrumb1);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+
+ // click Test3
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ let test3Span = subcategorySpans.find((span) => span.textContent.includes('Test3'));
+ await testUtils.dom.click(test3Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['3']);
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2);
+
+ // click Test6
+ let test6Span = subcategorySpans.find((span) => span.textContent.includes('Test6'));
+ await testUtils.dom.click(test6Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['6']);
+ let breadcrumbButtons = [...parent.el.querySelectorAll('.breadcrumb-button')];
+ assert.ok(breadcrumbButtons.length === 4);
+
+ // Now check subcategory buttons with images
+ pos.config.iface_display_categ_images = true;
+
+ let breadcrumbHome = parent.el.querySelector('.breadcrumb-home');
+ await testUtils.dom.click(breadcrumbHome);
+ await testUtils.nextTick();
+ assert.verifySteps(['0']);
+ assert.ok(
+ !parent.el.querySelector('.category-list').classList.contains('simple'),
+ 'Category list should not have simple class'
+ );
+ let categoryButtons = [...parent.el.querySelectorAll('.category-button')];
+ assert.ok(categoryButtons.length === 2, 'There should be 2 subcategories for Root');
+
+ // The following tests the search bar
+
+ const wait = (ms) => {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+ };
+
+ const inputEl = parent.el.querySelector('.searchbox input');
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'A' });
+ // Triggering keyup event doesn't type the key to the input
+ // so we manually assign the value of the input.
+ inputEl.value = 'A';
+ await wait(30);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'B' });
+ inputEl.value = 'AB';
+ await wait(30);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'C' });
+ inputEl.value = 'ABC';
+ await wait(110);
+ // Only after waiting for more than 100ms that update-search is triggered
+ // because the method is debounced.
+ assert.verifySteps(['ABC']);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'D' });
+ inputEl.value = 'ABCD';
+ await wait(110);
+ assert.verifySteps(['ABCD']);
+
+ // clear the search bar
+ await testUtils.dom.click(parent.el.querySelector('.search-clear.right'));
+ await testUtils.nextTick();
+ assert.verifySteps(['cleared']);
+ assert.ok(inputEl.value === '', 'value of the input element should be empty');
+
+ pos.config = old_config;
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('ProductList, ProductItem', async function (assert) {
+ assert.expect(10);
+
+ // patch imageUrl and price of ProductItem component
+ const MockProductItemExt = (X) =>
+ class extends X {
+ get imageUrl() {
+ return 'data:,';
+ }
+ get price() {
+ return this.props.product.price;
+ }
+ };
+
+ const extension = Registries.Component.extend('ProductItem', MockProductItemExt);
+ extension.compile();
+
+ const dummyProducts = [
+ { id: 0, display_name: 'Burger', price: '$10' },
+ { id: 1, display_name: 'Water', price: '$2' },
+ { id: 2, display_name: 'Chair', price: '$25' },
+ ];
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ searchWord: '', products: dummyProducts });
+ useListener('click-product', this._clickProduct);
+ }
+ _clickProduct({ detail: product }) {
+ assert.step(product.display_name);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ProductList products="state.products" searchWord="state.searchWord" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // Check if there are 3 products listed
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 3,
+ 'There should be 3 products listed'
+ );
+
+ // Check contents of product item and click
+ const product1el = parent.el.querySelector(
+ 'article.product[aria-labelledby="article_product_1"]'
+ );
+ assert.ok(product1el.querySelector('.product-img img[alt="Water"]'));
+ assert.ok(product1el.querySelector('.product-img .price-tag').textContent.includes('$2'));
+ await testUtils.dom.click(product1el);
+ await testUtils.nextTick();
+ assert.verifySteps(['Water']);
+
+ // Remove one product, check if only two is listed
+ parent.state.products.splice(0, 1);
+ await testUtils.nextTick();
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 2,
+ 'There should be 2 products listed after removing the first item'
+ );
+
+ // Remove all products, check if empty message is There are no products in this category
+ parent.state.products.splice(0, parent.state.products.length);
+ await testUtils.nextTick();
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 0,
+ 'There should be 0 products listed after removing everything'
+ );
+ assert.ok(
+ parent.el
+ .querySelector('.product-list-empty p')
+ .textContent.includes('There are no products in this category.')
+ );
+
+ // change the searchWord to 'something', check if empty message is No results found
+ parent.state.searchWord = 'something';
+ await testUtils.nextTick();
+ assert.ok(
+ parent.el
+ .querySelector('.product-list-empty p')
+ .textContent.includes('No results found for')
+ );
+ assert.ok(
+ parent.el.querySelector('.product-list-empty p b').textContent.includes('something')
+ );
+
+ extension.remove();
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('Orderline', async function (assert) {
+ assert.expect(10);
+
+ class Parent extends PosComponent {
+ constructor(product) {
+ super();
+ useListener('select-line', this._selectLine);
+ useListener('edit-pack-lot-lines', this._editPackLotLines);
+ this.order.add_product(product);
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ get line() {
+ return this.env.pos.get_order().get_orderlines()[0];
+ }
+ _selectLine() {
+ assert.step('select-line');
+ }
+ _editPackLotLines() {
+ assert.step('edit-pack-lot-lines');
+ }
+ willUnmount() {
+ this.order.remove_orderline(this.line);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <Orderline line="line" />
+ </div>
+ `;
+
+ const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
+ // patch chair2 to have tracking
+ chair2.tracking = 'serial';
+
+ // 1. Test orderline without lot icon
+
+ let parent = new Parent(chair1);
+ await parent.mount(testUtils.prepareTarget());
+
+ let line = parent.el.querySelector('li.orderline');
+ assert.ok(line);
+ assert.notOk(line.querySelector('.line-lot-icon'), 'there should be no lot icon');
+ await testUtils.dom.click(line);
+ assert.verifySteps(['select-line']);
+
+ parent.unmount();
+ parent.destroy();
+
+ // 2. Test orderline with lot icon
+
+ parent = new Parent(chair2);
+ await parent.mount(testUtils.prepareTarget());
+
+ line = parent.el.querySelector('li.orderline');
+ const lotIcon = line.querySelector('.line-lot-icon');
+ assert.ok(line);
+ assert.ok(lotIcon, 'there should be lot icon');
+ await testUtils.dom.click(line);
+ assert.verifySteps(['select-line']);
+ await testUtils.dom.click(lotIcon);
+ assert.verifySteps(['edit-pack-lot-lines']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('OrderWidget', async function (assert) {
+ assert.expect(8);
+
+ // OrderWidget is dependent on its parent's rerendering
+ class Parent extends PosComponent {
+ mounted() {
+ this.env.pos.on('change:selectedOrder', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <OrderWidget />
+ </div>
+ `;
+
+ const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // current order is empty
+ assert.notOk(parent.el.querySelector('.summary'));
+ assert.ok(parent.el.querySelector('.order-empty'));
+
+ // add line to the current order
+ const order1 = parent.env.pos.get_order();
+ order1.add_product(chair1);
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.summary'));
+ assert.notOk(parent.el.querySelector('.order-empty'));
+
+ // selected new order, new order is empty
+ const order2 = parent.env.pos.add_new_order();
+ await testUtils.nextTick();
+ assert.notOk(parent.el.querySelector('.summary'));
+ assert.ok(parent.el.querySelector('.order-empty'));
+
+ // add line to the current order
+ order2.add_product(chair2);
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.summary'));
+ assert.notOk(parent.el.querySelector('.order-empty'));
+
+ parent.env.pos.delete_current_order();
+ parent.env.pos.delete_current_order();
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_popups.js b/addons/point_of_sale/static/tests/unit/test_popups.js
new file mode 100644
index 00000000000..205d1b2413e
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_popups.js
@@ -0,0 +1,180 @@
+odoo.define('point_of_sale.test_popups', function(require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+ const testUtils = require('web.test_utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+
+ QUnit.module('unit tests for Popups', {
+ before() {
+ class Root extends PopupControllerMixin(PosComponent) {
+ static template = xml`
+ <div>
+ <t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />
+ </div>
+ `;
+ }
+ Root.env = makePosTestEnv();
+ this.Root = Root;
+ Registries.Component.freeze();
+ },
+ });
+
+ QUnit.test('ConfirmPopup', async function(assert) {
+ assert.expect(6);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show popup and confirm
+ promResponse = root.showPopup('ConfirmPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+
+ // Step: show popup then cancel
+ promResponse = root.showPopup('ConfirmPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.cancel'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: check texts
+ promResponse = root.showPopup('ConfirmPopup', {
+ title: 'Are you sure?',
+ body: 'Are you having fun?',
+ confirmText: 'Hell Yeah!',
+ cancelText: 'Are you kidding me?',
+ });
+ await testUtils.nextTick();
+ assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
+ assert.strictEqual(root.el.querySelector('.body').innerText.trim(), 'Are you having fun?');
+ assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
+ assert.strictEqual(
+ root.el.querySelector('.cancel').innerText.trim(),
+ 'Are you kidding me?'
+ );
+
+ root.unmount();
+ root.destroy();
+ });
+
+ QUnit.test('NumberPopup', async function(assert) {
+ assert.expect(8);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show NumberPopup and confirm with empty buffer
+ promResponse = root.showPopup('NumberPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(userResponse.payload, "");
+
+ // Step: show NumberPopup and cancel
+ promResponse = root.showPopup('NumberPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(root.el.querySelector('.cancel'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: show NumberPopup and confirm with filled buffer, new title, new text
+ promResponse = root.showPopup('NumberPopup', {
+ title: 'Are you sure?',
+ confirmText: 'Hell Yeah!',
+ cancelText: 'Are you kidding me?',
+ });
+ await testUtils.nextTick();
+ let nodes = Array.from(root.el.querySelectorAll('button'));
+ testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "7"), 'mousedown');
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "+10"), 'mousedown');
+ await testUtils.nextTick();
+ assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
+ assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
+ assert.strictEqual(root.el.querySelector('.cancel').innerText.trim(), 'Are you kidding me?');
+ testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(userResponse.payload, "17");
+
+ root.unmount();
+ root.destroy();
+ });
+
+ QUnit.test('EditListPopup', async function(assert) {
+ assert.expect(7);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show popup and confirm
+ promResponse = root.showPopup('EditListPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([]));
+
+ // Step: show popup and cancel
+ promResponse = root.showPopup('EditListPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.cancel'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: show popup and confirm with a default array
+ let defaultArray = ["Banana", "Cherry"];
+ promResponse = root.showPopup('EditListPopup', {
+ title: "Fruits",
+ isSingleItem: false,
+ array: defaultArray,
+ });
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+
+ assert.strictEqual(userResponse.confirmed, true);
+ let i = 0;
+ defaultArray = defaultArray.map((item) => Object.assign({}, { _id: i++ }, { 'text': item}));
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify(defaultArray));
+
+ // Step: show popup and confirm with a new array
+ promResponse = root.showPopup('EditListPopup', {
+ title: "Fruits",
+ isSingleItem: false,
+ array: ["Banana", "Cherry"],
+ });
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.fa-trash-o'));
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([{ _id: 1, text: "Cherry"}]));
+
+ root.unmount();
+ root.destroy();
+ });
+});
diff --git a/addons/point_of_sale/tests/__init__.py b/addons/point_of_sale/tests/__init__.py
index 43cf85459af..d108782cf17 100644
--- a/addons/point_of_sale/tests/__init__.py
+++ b/addons/point_of_sale/tests/__init__.py
@@ -14,3 +14,4 @@ from . import test_pos_multiple_receivable_accounts
from . import test_pos_other_currency_config
from . import test_pos_with_fiscal_position
from . import test_pos_stock_account
+from . import test_js
diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py
index 02447e04ab4..63807196588 100644
--- a/addons/point_of_sale/tests/test_frontend.py
+++ b/addons/point_of_sale/tests/test_frontend.py
@@ -51,6 +51,15 @@ class TestPointOfSaleHttpCommon(odoo.tests.HttpCase):
tip = self.env.ref('point_of_sale.product_product_tip')
(all_pos_product - discount - tip)._write({'active': False})
+ # In DESKS categ: Desk Pad
+ pos_categ_desks = env.ref('point_of_sale.pos_category_desks')
+
+ # In DESKS categ: Whiteboard Pen
+ pos_categ_misc = env.ref('point_of_sale.pos_category_miscellaneous')
+
+ # In CHAIR categ: Letter Tray
+ pos_categ_chairs = env.ref('point_of_sale.pos_category_chairs')
+
# test an extra price on an attribute
pear = env['product.product'].create({
'name': 'Whiteboard Pen',
@@ -59,6 +68,7 @@ class TestPointOfSaleHttpCommon(odoo.tests.HttpCase):
'taxes_id': False,
'weight': 0.01,
'to_weight': True,
+ 'pos_categ_id': pos_categ_misc.id,
})
wall_shelf = env['product.product'].create({
'name': 'Wall Shelf Unit',
@@ -89,12 +99,14 @@ class TestPointOfSaleHttpCommon(odoo.tests.HttpCase):
'available_in_pos': True,
'list_price': 1.98,
'taxes_id': False,
+ 'pos_categ_id': pos_categ_desks.id,
})
letter_tray = env['product.product'].create({
'name': 'Letter Tray',
'available_in_pos': True,
'list_price': 4.80,
'taxes_id': False,
+ 'pos_categ_id': pos_categ_chairs.id,
})
desk_organizer = env['product.product'].create({
'name': 'Desk Organizer',
@@ -409,9 +421,12 @@ class TestUi(TestPointOfSaleHttpCommon):
# this you end up with js, css but no qweb.
self.env['ir.module.module'].search([('name', '=', 'point_of_sale')], limit=1).state = 'installed'
- self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'pos_pricelist', login="admin")
-
- self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'pos_basic_order', login="admin")
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'pos_pricelist', login="admin", step_delay=50)
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'pos_basic_order', login="admin", step_delay=50)
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'ProductScreenTour', login="admin", step_delay=50)
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'PaymentScreenTour', login="admin", step_delay=50)
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'ReceiptScreenTour', login="admin", step_delay=50)
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'ChromeTour', login="admin", step_delay=50)
for order in self.env['pos.order'].search([]):
self.assertEqual(order.state, 'paid', "Validated order has payment of " + str(order.amount_paid) + " and total of " + str(order.amount_total))
diff --git a/addons/point_of_sale/tests/test_js.py b/addons/point_of_sale/tests/test_js.py
new file mode 100644
index 00000000000..c021075b95a
--- /dev/null
+++ b/addons/point_of_sale/tests/test_js.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo.addons.web.tests.test_js
+import odoo.tests
+
+
+@odoo.tests.tagged("post_install", "-at_install")
+class WebSuite(odoo.tests.HttpCase):
+ def setUp(self):
+ super().setUp()
+ env = self.env(user=self.env.ref('base.user_admin'))
+ self.main_pos_config = env.ref('point_of_sale.pos_config_main')
+
+ def test_pos_js(self):
+ # open a session, the /pos/web controller will redirect to it
+ self.main_pos_config.open_session_cb(check_coa=False)
+
+ # point_of_sale desktop test suite
+ self.browser_js(
+ "/pos/web/tests?mod=web&failfast", "", "", login="admin", timeout=1800
+ )
diff --git a/addons/point_of_sale/views/point_of_sale.xml b/addons/point_of_sale/views/customer_facing_display.xml
index 7437377e3df..e94686042d6 100644
--- a/addons/point_of_sale/views/point_of_sale.xml
+++ b/addons/point_of_sale/views/customer_facing_display.xml
@@ -1,29 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
- <template id="assets" name="point_of_sale assets">
- <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/fonts.scss"/>
- <link rel="stylesheet" href="/web/static/lib/fontawesome/css/font-awesome.css"/>
- <link rel="stylesheet" href="/point_of_sale/static/src/css/pos.css" id="pos-stylesheet"/>
- <link rel="stylesheet" href="/point_of_sale/static/src/css/keyboard.css"/>
- <link rel="stylesheet" href="/point_of_sale/static/src/css/pos_receipts.css"/>
- <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fontawesome_overridden.scss"/>
-
- <script type="text/javascript" src="/point_of_sale/static/lib/backbone/backbone.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/lib/waitfont.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/lib/sha1.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/main.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/db.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/models.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/widget_base.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/keyboard.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/barcode_reader.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/chrome.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/devices.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/gui.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/popups.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/screens.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/payment.js"></script>
- </template>
<template id="customer_facing_display_html" >
<t name="point_of_sale.customer_facing_display_html" >
diff --git a/addons/point_of_sale/views/point_of_sale_template.xml b/addons/point_of_sale/views/point_of_sale_template.xml
deleted file mode 100644
index 162a7a4bd49..00000000000
--- a/addons/point_of_sale/views/point_of_sale_template.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <template id="assets_backend" name="hr assets" inherit_id="web.assets_backend">
- <xpath expr="." position="inside">
- <link rel="stylesheet" type="text/scss" href="/point_of_sale/static/src/scss/pos_dashboard.scss"/>
- <link rel="stylesheet" href="/point_of_sale/static/src/css/pos_receipts.css"/>
- <script type="text/javascript" src="/point_of_sale/static/src/js/tours/point_of_sale.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/src/js/printers.js"></script>
- <script type="text/javascript" src="/point_of_sale/static/lib/html2canvas.js"></script>
- </xpath>
- </template>
-
- <template id="assets_tests" name="Point Of Sale Assets Tests" inherit_id="web.assets_tests">
- <xpath expr="." position="inside">
- <script type="text/javascript" src="/point_of_sale/static/tests/tours/point_of_sale.js"></script>
- </xpath>
- </template>
-</odoo>
diff --git a/addons/point_of_sale/views/pos_assets_common.xml b/addons/point_of_sale/views/pos_assets_common.xml
new file mode 100644
index 00000000000..05729983f5d
--- /dev/null
+++ b/addons/point_of_sale/views/pos_assets_common.xml
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<template id="point_of_sale.assets" name="point_of_sale assets">
+ <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/fonts.scss"/>
+ <link rel="stylesheet" href="/web/static/lib/fontawesome/css/font-awesome.css"/>
+ <link rel="stylesheet" href="/point_of_sale/static/src/css/pos.css" id="pos-stylesheet"/>
+ <link rel="stylesheet" href="/point_of_sale/static/src/css/keyboard.css"/>
+ <link rel="stylesheet" href="/point_of_sale/static/src/css/pos_receipts.css"/>
+ <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fontawesome_overridden.scss"/>
+
+ <script type="text/javascript" src="/point_of_sale/static/lib/backbone/backbone.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/lib/waitfont.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/lib/sha1.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/utils.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ClassRegistry.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/PosComponent.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ComponentRegistry.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Registries.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/db.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/models.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/keyboard.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/barcode_reader.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Gui.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/PopupControllerMixin.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Chrome.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/devices.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/payment.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/custom_hooks.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/CashierName.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Misc/Draggable.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Misc/NotificationSound.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/ErrorPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/ConfirmPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/TextInputPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/TextAreaPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/SelectionPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/EditListInput.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/EditListPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/NumberPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/OrderImportPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Misc/NumberBuffer.js"></script>
+</template>
+
+<template id="point_of_sale.assets_backend" name="hr assets" inherit_id="web.assets_backend">
+ <xpath expr="." position="inside">
+ <link rel="stylesheet" type="text/scss" href="/point_of_sale/static/src/scss/pos_dashboard.scss"/>
+ <link rel="stylesheet" href="/point_of_sale/static/src/css/pos_receipts.css"/>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/tours/point_of_sale.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/printers.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/lib/html2canvas.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/debug_manager.js"></script>
+ </xpath>
+</template>
+
+<template id="point_of_sale.pos_assets_backend" name="POS UI Backend Assets" inherit_id="web.assets_backend" primary="True">
+ <xpath expr="//script[contains(@src, '/mail/static/src/js/services/mail_service.js')]" position="replace"></xpath>
+</template>
+
+<template id="point_of_sale.assets_common" name="POS Assets Common">
+ <t t-call-assets="point_of_sale.assets" t-js="false" />
+ <t t-call-assets="web.assets_common" t-css="false" />
+ <t t-call-assets="point_of_sale.pos_assets_backend" t-css="false" />
+ <t t-call-assets="point_of_sale.assets" t-css="false" />
+</template>
+
+</odoo>
diff --git a/addons/point_of_sale/views/pos_assets_index.xml b/addons/point_of_sale/views/pos_assets_index.xml
new file mode 100644
index 00000000000..057f72454d8
--- /dev/null
+++ b/addons/point_of_sale/views/pos_assets_index.xml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<!-- This contains the tour assets. They are included during testing. -->
+<template id="assets_tests" name="Point Of Sale Assets Tests" inherit_id="web.assets_tests">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/utils.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/point_of_sale.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/ProductScreen.tour.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/PaymentScreen.tour.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/ReceiptScreen.tour.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/Chrome.tour.js"></script>
+ </xpath>
+</template>
+
+<template id="point_of_sale.index" name="POS Index">&lt;!DOCTYPE html&gt;
+<html>
+ <head>
+ <title>Odoo POS</title>
+
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
+ <meta http-equiv="content-type" content="text/html, charset=utf-8" />
+
+ <meta name="viewport" content=" width=1024, user-scalable=no"/>
+ <meta name="apple-mobile-web-app-capable" content="yes"/>
+ <meta name="mobile-web-app-capable" content="yes"/>
+
+ <link rel="shortcut icon" sizes="196x196" href="/point_of_sale/static/src/img/touch-icon-196.png"/>
+ <link rel="shortcut icon" sizes="128x128" href="/point_of_sale/static/src/img/touch-icon-128.png"/>
+ <link rel="apple-touch-icon" href="/point_of_sale/static/src/img/touch-icon-iphone.png"/>
+ <link rel="apple-touch-icon" sizes="76x76" href="/point_of_sale/static/src/img/touch-icon-ipad.png"/>
+ <link rel="apple-touch-icon" sizes="120x120" href="/point_of_sale/static/src/img/touch-icon-iphone-retina.png"/>
+ <link rel="apple-touch-icon" sizes="152x152" href="/point_of_sale/static/src/img/touch-icon-ipad-retina.png"/>
+
+ <style> body { background: #222; } </style>
+
+ <link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
+
+ <script type="text/javascript">
+ var odoo = <t t-raw="json.dumps({
+ 'csrf_token': request.csrf_token(None),
+ 'session_info': session_info,
+ 'login_number': login_number,
+ 'debug': debug,
+ })"/>;
+ </script>
+
+ <t t-call="point_of_sale.assets_common"/>
+ <t t-call="web.conditional_assets_tests"/>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/main.js"></script>
+ </head>
+ <body>
+ </body>
+</html>
+</template>
+
+</odoo>
diff --git a/addons/point_of_sale/views/pos_assets_qunit.xml b/addons/point_of_sale/views/pos_assets_qunit.xml
new file mode 100644
index 00000000000..41c0a6a1166
--- /dev/null
+++ b/addons/point_of_sale/views/pos_assets_qunit.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+<template id="point_of_sale.qunit_suite_assets">
+ <t t-call-assets="point_of_sale.pos_assets_backend" t-js="false"/>
+
+ <link type="text/css" rel="stylesheet" href="/web/static/lib/qunit/qunit-2.9.1.css"/>
+ <script type="text/javascript" src="/web/static/lib/qunit/qunit-2.9.1.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/qunit_config.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/qunit_asserts.js"></script>
+
+ <style>
+ body {
+ position: relative;
+ }
+ body:not(.debug) .modal-backdrop, body:not(.debug) .modal, body:not(.debug) .ui-autocomplete {
+ opacity: 0 !important;
+ }
+ #qunit-testrunner-toolbar label {
+ font-weight: inherit;
+ margin-bottom: inherit;
+ }
+ #qunit-testrunner-toolbar input[type=text] {
+ width: inherit;
+ display: inherit;
+ }
+ </style>
+
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_create.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_control_panel.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_dom.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_fields.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_file.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_form.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_graph.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_kanban.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_mock.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_modal.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils_pivot.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_utils.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/mock_server.js"></script>
+ <script type="text/javascript" src="/web/static/tests/helpers/test_env.js"></script>
+
+ <script type="text/javascript" src="/web/static/tests/component_extension_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/boot_tests.js"></script>
+
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/helpers/test_env.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/helpers/test_main.js"></script>
+</template>
+
+<template id="point_of_sale.qunit_suite_tests">
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/test_ComponentRegistry.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/test_ChromeWidgets.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/test_ProductScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/test_PaymentScreen.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/unit/test_popups.js"></script>
+</template>
+
+<template id="point_of_sale.qunit_suite">
+ <t t-call="web.layout">
+ <t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
+ <t t-set="title">Point of Sale Tests</t>
+ <t t-set="head">
+ <!-- we need session_info in order to properly instantiate PosModel -->
+ <script type="text/javascript">
+ var odoo = <t t-raw="json.dumps({
+ 'csrf_token': request.csrf_token(None),
+ 'session_info': session_info,
+ 'debug': debug,
+ })"/>;
+ </script>
+
+ <t t-call="point_of_sale.assets_common" />
+ <t t-call="point_of_sale.qunit_suite_assets" />
+ <t t-call="point_of_sale.qunit_suite_tests" />
+ </t>
+
+ <div id="qunit"/>
+ <div id="qunit-fixture"/>
+ </t>
+</template>
+
+</odoo>
diff --git a/addons/point_of_sale/views/pos_templates.xml b/addons/point_of_sale/views/pos_templates.xml
deleted file mode 100644
index 999f41f1195..00000000000
--- a/addons/point_of_sale/views/pos_templates.xml
+++ /dev/null
@@ -1,67 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
-<template id="pos_assets_backend" name="POS UI Backend Assets" inherit_id="web.assets_backend" primary="True">
- <xpath expr="//script[contains(@src, '/mail/static/src/js/services/mail_service.js')]" position="replace"></xpath>
-</template>
-
-<template id="index" name="POS Index">&lt;!DOCTYPE html&gt;
-<html>
- <head>
- <title>Odoo POS</title>
-
- <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
- <meta http-equiv="content-type" content="text/html, charset=utf-8" />
-
- <meta name="viewport" content=" width=1024, user-scalable=no"/>
- <meta name="apple-mobile-web-app-capable" content="yes"/>
- <meta name="mobile-web-app-capable" content="yes"/>
-
- <link rel="shortcut icon" sizes="196x196" href="/point_of_sale/static/src/img/touch-icon-196.png"/>
- <link rel="shortcut icon" sizes="128x128" href="/point_of_sale/static/src/img/touch-icon-128.png"/>
- <link rel="apple-touch-icon" href="/point_of_sale/static/src/img/touch-icon-iphone.png"/>
- <link rel="apple-touch-icon" sizes="76x76" href="/point_of_sale/static/src/img/touch-icon-ipad.png"/>
- <link rel="apple-touch-icon" sizes="120x120" href="/point_of_sale/static/src/img/touch-icon-iphone-retina.png"/>
- <link rel="apple-touch-icon" sizes="152x152" href="/point_of_sale/static/src/img/touch-icon-ipad-retina.png"/>
-
- <style> body { background: #222; } </style>
-
- <link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
-
- <script type="text/javascript">
- var odoo = <t t-raw="json.dumps({
- 'csrf_token': request.csrf_token(None),
- 'session_info': session_info,
- 'login_number': login_number,
- 'debug': debug,
- })"/>;
- </script>
-
- <t t-call-assets="web.assets_common" t-css="false"/>
- <t t-call-assets="point_of_sale.pos_assets_backend" t-css="false"/>
- <t t-call-assets="point_of_sale.assets"/>
- <t t-call="web.conditional_assets_tests"/>
-
- <script type="text/javascript" id="loading-script" t-raw="init">
- odoo.define('web.web_client', function (require) {
- var WebClient = require('web.AbstractWebClient');
- var web_client = new WebClient();
-
- web_client._title_changed = function() {};
- web_client.show_application = function() {
- return web_client.action_manager.do_action("pos.ui");
- };
-
- $(function () {
- web_client.setElement($(document.body));
- web_client.start();
- });
- return web_client;
- });
- </script>
- </head>
- <body>
- </body>
-</html>
-</template>
-
-</odoo>