From c6397ab3b3654da336a4269a81d5d91016baf520 Mon Sep 17 00:00:00 2001 From: Joseph Caburnay Date: Tue, 5 May 2020 07:47:10 +0000 Subject: [PATCH] [REF] point_of_sale: migration to OWL framework --- addons/point_of_sale/__manifest__.py | 65 +- addons/point_of_sale/controllers/main.py | 15 + addons/point_of_sale/static/src/css/pos.css | 79 +- addons/point_of_sale/static/src/js/Chrome.js | 426 +++ .../src/js/ChromeWidgets/CashierName.js | 23 + .../js/ChromeWidgets/ClientScreenButton.js | 87 + .../src/js/ChromeWidgets/DebugWidget.js | 161 + .../src/js/ChromeWidgets/HeaderButton.js | 36 + .../src/js/ChromeWidgets/OrderSelector.js | 49 + .../src/js/ChromeWidgets/ProxyStatus.js | 91 + .../src/js/ChromeWidgets/SaleDetailsButton.js | 38 + .../src/js/ChromeWidgets/SyncNotification.js | 37 + .../static/src/js/ClassRegistry.js | 262 ++ .../static/src/js/ComponentRegistry.js | 29 + addons/point_of_sale/static/src/js/Gui.js | 60 + .../static/src/js/Misc/Draggable.js | 143 + .../static/src/js/Misc/NotificationSound.js | 19 + .../static/src/js/Misc/NumberBuffer.js | 277 ++ .../static/src/js/PopupControllerMixin.js | 44 + .../src/js/Popups/AbstractAwaitablePopup.js | 60 + .../static/src/js/Popups/ConfirmPopup.js | 20 + .../static/src/js/Popups/EditListInput.js | 19 + .../static/src/js/Popups/EditListPopup.js | 105 + .../static/src/js/Popups/ErrorBarcodePopup.js | 26 + .../static/src/js/Popups/ErrorPopup.js | 24 + .../src/js/Popups/ErrorTracebackPopup.js | 44 + .../static/src/js/Popups/NumberPopup.js | 71 + .../static/src/js/Popups/OfflineErrorPopup.js | 29 + .../static/src/js/Popups/OrderImportPopup.js | 27 + .../static/src/js/Popups/SelectionPopup.js | 63 + .../static/src/js/Popups/TextAreaPopup.js | 39 + .../static/src/js/Popups/TextInputPopup.js | 34 + .../static/src/js/PosComponent.js | 59 + .../point_of_sale/static/src/js/Registries.js | 11 + .../Screens/ClientListScreen/ClientDetails.js | 22 + .../ClientListScreen/ClientDetailsEdit.js | 112 + .../js/Screens/ClientListScreen/ClientLine.js | 21 + .../ClientListScreen/ClientListScreen.js | 167 ++ .../PaymentScreen/PSNumpadInputButton.js | 17 + .../PaymentScreen/PaymentMethodButton.js | 13 + .../js/Screens/PaymentScreen/PaymentScreen.js | 422 +++ .../PaymentScreenElectronicPayment.js | 23 + .../PaymentScreen/PaymentScreenNumpad.js | 18 + .../PaymentScreenPaymentLines.js | 39 + .../Screens/ProductScreen/ActionpadWidget.js | 25 + .../ProductScreen/CategoryBreadcrumb.js | 13 + .../Screens/ProductScreen/CategoryButton.js | 18 + .../ProductScreen/CategorySimpleButton.js | 13 + .../ControlButtons/SetFiscalPositionButton.js | 80 + .../ControlButtons/SetPricelistButton.js | 67 + .../ProductScreen/HomeCategoryBreadcrumb.js | 13 + .../js/Screens/ProductScreen/NumpadWidget.js | 47 + .../js/Screens/ProductScreen/OrderSummary.js | 13 + .../js/Screens/ProductScreen/OrderWidget.js | 110 + .../src/js/Screens/ProductScreen/Orderline.js | 25 + .../js/Screens/ProductScreen/ProductItem.js | 49 + .../js/Screens/ProductScreen/ProductList.js | 13 + .../js/Screens/ProductScreen/ProductScreen.js | 338 +++ .../Screens/ProductScreen/ProductsWidget.js | 72 + .../ProductsWidgetControlPanel.js | 29 + .../js/Screens/ReceiptScreen/OrderReceipt.js | 47 + .../js/Screens/ReceiptScreen/ReceiptScreen.js | 132 + .../ReceiptScreen/WrappedProductNameLines.js | 18 + .../src/js/Screens/ScaleScreen/ScaleScreen.js | 102 + .../static/src/js/barcode_reader.js | 95 +- addons/point_of_sale/static/src/js/chrome.js | 937 ------ .../static/src/js/custom_hooks.js | 141 + addons/point_of_sale/static/src/js/db.js | 1 + .../static/src/js/debug_manager.js | 20 + addons/point_of_sale/static/src/js/gui.js | 464 --- addons/point_of_sale/static/src/js/main.js | 29 +- addons/point_of_sale/static/src/js/models.js | 467 +-- addons/point_of_sale/static/src/js/popups.js | 343 --- .../point_of_sale/static/src/js/printers.js | 74 +- addons/point_of_sale/static/src/js/screens.js | 2625 ----------------- addons/point_of_sale/static/src/js/utils.js | 21 + .../static/src/js/widget_base.js | 83 - .../point_of_sale/static/src/xml/Chrome.xml | 126 + .../src/xml/ChromeWidgets/CashierName.xml | 12 + .../xml/ChromeWidgets/ClientScreenButton.xml | 19 + .../src/xml/ChromeWidgets/DebugWidget.xml | 93 + .../src/xml/ChromeWidgets/HeaderButton.xml | 11 + .../src/xml/ChromeWidgets/OrderSelector.xml | 34 + .../src/xml/ChromeWidgets/ProxyStatus.xml | 28 + .../xml/ChromeWidgets/SaleDetailsButton.xml | 13 + .../xml/ChromeWidgets/SyncNotification.xml | 29 + .../static/src/xml/Misc/Draggable.xml | 8 + .../static/src/xml/Misc/NotificationSound.xml | 8 + .../static/src/xml/Popups/ConfirmPopup.xml | 27 + .../static/src/xml/Popups/EditListInput.xml | 13 + .../static/src/xml/Popups/EditListPopup.xml | 28 + .../src/xml/Popups/ErrorBarcodePopup.xml | 28 + .../static/src/xml/Popups/ErrorPopup.xml | 22 + .../src/xml/Popups/ErrorTracebackPopup.xml | 36 + .../static/src/xml/Popups/NumberPopup.xml | 70 + .../src/xml/Popups/OfflineErrorPopup.xml | 25 + .../src/xml/Popups/OrderImportPopup.xml | 39 + .../static/src/xml/Popups/SelectionPopup.xml | 29 + .../static/src/xml/Popups/TextAreaPopup.xml | 25 + .../static/src/xml/Popups/TextInputPopup.xml | 28 + .../static/src/xml/SaleDetailsReport.xml | 75 + .../ClientListScreen/ClientDetails.xml | 95 + .../ClientListScreen/ClientDetailsEdit.xml | 129 + .../Screens/ClientListScreen/ClientLine.xml | 19 + .../ClientListScreen/ClientListScreen.xml | 74 + .../PaymentScreen/PSNumpadInputButton.xml | 18 + .../PaymentScreen/PaymentMethodButton.xml | 11 + .../Screens/PaymentScreen/PaymentScreen.xml | 77 + .../PaymentScreenElectronicPayment.xml | 81 + .../PaymentScreen/PaymentScreenNumpad.xml | 31 + .../PaymentScreenPaymentLines.xml | 115 + .../Screens/ProductScreen/ActionpadWidget.xml | 25 + .../ProductScreen/CategoryBreadcrumb.xml | 15 + .../Screens/ProductScreen/CategoryButton.xml | 15 + .../ProductScreen/CategorySimpleButton.xml | 11 + .../SetFiscalPositionButton.xml | 12 + .../ControlButtons/SetPricelistButton.xml | 11 + .../ProductScreen/HomeCategoryBreadcrumb.xml | 13 + .../Screens/ProductScreen/NumpadWidget.xml | 37 + .../Screens/ProductScreen/OrderSummary.xml | 23 + .../xml/Screens/ProductScreen/OrderWidget.xml | 28 + .../xml/Screens/ProductScreen/Orderline.xml | 66 + .../xml/Screens/ProductScreen/ProductItem.xml | 21 + .../xml/Screens/ProductScreen/ProductList.xml | 30 + .../Screens/ProductScreen/ProductScreen.xml | 38 + .../Screens/ProductScreen/ProductsWidget.xml | 25 + .../ProductsWidgetControlPanel.xml | 47 + .../Screens/ReceiptScreen/OrderReceipt.xml | 196 ++ .../Screens/ReceiptScreen/ReceiptScreen.xml | 33 + .../ReceiptScreen/WrappedProductNameLines.xml | 10 + .../xml/Screens/ScaleScreen/ScaleScreen.xml | 36 + .../static/src/xml/debug_manager.xml | 10 + addons/point_of_sale/static/src/xml/pos.xml | 1778 ----------- .../static/tests/tours/Chrome.tour.js | 74 + .../static/tests/tours/PaymentScreen.tour.js | 75 + .../static/tests/tours/ProductScreen.tour.js | 99 + .../static/tests/tours/ReceiptScreen.tour.js | 35 + .../tests/tours/helpers/ChromeTourMethods.js | 42 + .../tours/helpers/ErrorPopupTourMethods.js | 30 + .../tours/helpers/NumberPopupTourMethods.js | 69 + .../tours/helpers/PaymentScreenTourMethods.js | 195 ++ .../tours/helpers/ProductScreenTourMethods.js | 177 ++ .../tours/helpers/ReceiptScreenTourMethods.js | 59 + .../helpers/SelectionPopupTourMethods.js | 39 + .../static/tests/tours/helpers/utils.js | 144 + .../static/tests/tours/point_of_sale.js | 38 +- .../static/tests/unit/helpers/test_env.js | 43 + .../static/tests/unit/helpers/test_main.js | 6 + .../static/tests/unit/test_ChromeWidgets.js | 135 + .../tests/unit/test_ComponentRegistry.js | 414 +++ .../static/tests/unit/test_PaymentScreen.js | 310 ++ .../static/tests/unit/test_ProductScreen.js | 594 ++++ .../static/tests/unit/test_popups.js | 180 ++ addons/point_of_sale/tests/__init__.py | 1 + addons/point_of_sale/tests/test_frontend.py | 21 +- addons/point_of_sale/tests/test_js.py | 22 + ...f_sale.xml => customer_facing_display.xml} | 24 - .../views/point_of_sale_template.xml | 18 - .../point_of_sale/views/pos_assets_common.xml | 108 + .../point_of_sale/views/pos_assets_index.xml | 64 + .../point_of_sale/views/pos_assets_qunit.xml | 83 + addons/point_of_sale/views/pos_templates.xml | 67 - 162 files changed, 10443 insertions(+), 6629 deletions(-) create mode 100644 addons/point_of_sale/static/src/js/Chrome.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js create mode 100644 addons/point_of_sale/static/src/js/ClassRegistry.js create mode 100644 addons/point_of_sale/static/src/js/ComponentRegistry.js create mode 100644 addons/point_of_sale/static/src/js/Gui.js create mode 100644 addons/point_of_sale/static/src/js/Misc/Draggable.js create mode 100644 addons/point_of_sale/static/src/js/Misc/NotificationSound.js create mode 100644 addons/point_of_sale/static/src/js/Misc/NumberBuffer.js create mode 100644 addons/point_of_sale/static/src/js/PopupControllerMixin.js create mode 100644 addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/EditListInput.js create mode 100644 addons/point_of_sale/static/src/js/Popups/EditListPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/ErrorPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/NumberPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/SelectionPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/TextInputPopup.js create mode 100644 addons/point_of_sale/static/src/js/PosComponent.js create mode 100644 addons/point_of_sale/static/src/js/Registries.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js create mode 100644 addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js create mode 100644 addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js create mode 100644 addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js create mode 100644 addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js create mode 100644 addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js delete mode 100644 addons/point_of_sale/static/src/js/chrome.js create mode 100644 addons/point_of_sale/static/src/js/custom_hooks.js create mode 100644 addons/point_of_sale/static/src/js/debug_manager.js delete mode 100644 addons/point_of_sale/static/src/js/gui.js delete mode 100644 addons/point_of_sale/static/src/js/popups.js delete mode 100644 addons/point_of_sale/static/src/js/screens.js create mode 100644 addons/point_of_sale/static/src/js/utils.js delete mode 100644 addons/point_of_sale/static/src/js/widget_base.js create mode 100644 addons/point_of_sale/static/src/xml/Chrome.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml create mode 100644 addons/point_of_sale/static/src/xml/Misc/Draggable.xml create mode 100644 addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/EditListInput.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/SaleDetailsReport.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetails.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/debug_manager.xml delete mode 100644 addons/point_of_sale/static/src/xml/pos.xml create mode 100644 addons/point_of_sale/static/tests/tours/Chrome.tour.js create mode 100644 addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js create mode 100644 addons/point_of_sale/static/tests/tours/ProductScreen.tour.js create mode 100644 addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/utils.js create mode 100644 addons/point_of_sale/static/tests/unit/helpers/test_env.js create mode 100644 addons/point_of_sale/static/tests/unit/helpers/test_main.js create mode 100644 addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js create mode 100644 addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js create mode 100644 addons/point_of_sale/static/tests/unit/test_PaymentScreen.js create mode 100644 addons/point_of_sale/static/tests/unit/test_ProductScreen.js create mode 100644 addons/point_of_sale/static/tests/unit/test_popups.js create mode 100644 addons/point_of_sale/tests/test_js.py rename addons/point_of_sale/views/{point_of_sale.xml => customer_facing_display.xml} (63%) delete mode 100644 addons/point_of_sale/views/point_of_sale_template.xml create mode 100644 addons/point_of_sale/views/pos_assets_common.xml create mode 100644 addons/point_of_sale/views/pos_assets_index.xml create mode 100644 addons/point_of_sale/views/pos_assets_qunit.xml delete mode 100644 addons/point_of_sale/views/pos_templates.xml 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(, )`. + * 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. + * ``` + *
+ * + * + * + * + *
+ * ``` + * + * 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()` + * 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. + * `` + * 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` + *
+ * + * + *
+ * ` + * ``` + * + * 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} [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: } + * } + * ``` + * + * @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 should be like clicking the product. + * 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]); + } + }, + + remove_exclusive_callback: function (name, callback) { + if (!callback) { + delete this.exclusive_callbacks[name]; + return; + } + 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; - } - 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); + 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(''); - }, - - /* ---- 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"); - - $("",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'+subreceipt+''); - 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($("
+
+ +
+
+
+ +
+
+ + + + + + + +
+
+
+
+
+
+
+ + +
+
+
+
+ +
+ + +
+
+

+ +

+
+
+
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ + + +
+
+ + + +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ + + + + +
+ + +
+
+ + +
+
+ +
+ Change: +
+
+ + +
+
+
+ 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 @@ + + + + +
+ + + +
+
+ +
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 @@ + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
+ +
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 @@ + + + + + +
+
+

Debug Window

+
+ +
+

Electronic Scale

+
    +
  • + +
  • +
  • Set Weight
  • +
  • Reset
  • +
+ +

Barcode Scanner

+
    +
  • + +
  • +
  • Scan
  • +
  • Scan EAN-13
  • +
+ +

Orders

+ +
+ +

Hardware Status

+
    +
  • Weighing
  • +
  • + Refresh Display +
  • +
+

Hardware Events

+
    +
  • Open Cashbox
  • +
  • Print Receipt
  • +
  • Read Weighing Scale
  • +
+

Others

+
    +
  • + Buffer: + +
  • +
+
+
+ + + + 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 @@ + + + + +
+ +
+
+ +
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 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
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 @@ + + + + +
+ + + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
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 @@ + + + + +
+
+ +
+
+
+ +
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 @@ + + + + +
+ + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + +
+ + +
+
+ +
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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + + + + 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 @@ + + + + +
+ + +
+
+ +

+
+ +

+ +
+ +
+ + + + + + + x + + +
+ +
Discount: %
+
+
+
+ +
+
------------------------
+
+ +
+ Payments: +
+
+ + +
+ +
+
------------------------
+
+ +
+ Taxes: +
+
+ + +
+ +
+
------------------------
+
+ +
+ Total: + +
+ +
+
+
+
+

+
+ +
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 @@ + + + + +
+
+ Partner logo +
+
+ +
+
+
+ +
+
+
+
+
+ Address + + + + + + + N/A + +
+
+ Email + + + + + + + N/A + +
+
+ Phone + + + + + + + N/A + +
+
+
+
+ Barcode + + + + + + + N/A + +
+
+ Tax ID + + + + + + + N/A + +
+
+ Pricelist + + + + + + + N/A + +
+
+
+
+
+ +
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 @@ + + + + +
+
+ + Partner + + + + + +
+ +
+
+ +
+
+ +
+
+
+
+
+ Street + +
+
+ City + +
+
+ Postcode + +
+
+ State + +
+
+ Country + +
+
+
+
+ Language + +
+
+ Email + +
+
+ Phone + +
+
+ Barcode + +
+
+ Tax ID + +
+
+ Pricelist + +
+
+
+
+
+ +
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 @@ + + + + + + + + + + + + + + + + + + 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 @@ + + + + +
+
+
+ + + Cancel + + + + + + + + + + + + + +
+
+
+ +
+
+
+ + + + + + + + + + + + + +
NameAddressPhone
+
+
+
+
+
+
+
+
+ +
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 @@ + + + + + + + + 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 @@ + + + + +
+ +
+
+ +
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 @@ + + + + +
+
+
+ + + Back + +

Payment

+ + Validate + + +
+
+
+
+ + + +
+
+
+
+ +
+ +
+ +
+ +
+ +
+
+ + + + + + + Customer + + +
+
+ Invoice +
+
+ Email +
+
+ Tip +
+
+ Open Cashbox +
+
+
+
+
+
+ +
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 @@ + + + + +
+ +
+ Payment request pending +
+
+ Send +
+
+ +
+ Transaction cancelled +
+
+ Retry +
+
+ +
+ Connection error +
+
+ Force done +
+
+ +
+ Waiting for card +
+
+ Cancel +
+
+ +
+ Request sent +
+
+ +
+
+ +
+ Reversal request sent to terminal +
+
+ +
+
+ + +
+ Payment Successful +
+
+ Reverse +
+
+ +
+ Payment Successful +
+
+
+
+ +
+
+ Payment reversed +
+
+
+
+
+
+ +
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 @@ + + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + + Backspace + +
+
+ +
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 @@ + + + + +
+
+ +
+
+ Please select a payment method. +
+
+ +
+
+
+
+ Remaining + + + +
+
+ Total Due + + + +
+
+
+
+ Change + + + +
+
+
+
+
+
+
Tendered
+
Method
+
+
+
+ + +
+ +
+ +
+
+ +
+ +
+
+
+ +
+ +
+ + +
+ +
+
+
+ + + +
+ +
+
+ +
+
+ +
+ +
+ + +
+ +
+
+
+
+ +
+
+
+ + + 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 @@ + + + + +
+ + +
+
+ +
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 @@ + + + + + + Slash + + + + + + + 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 @@ + + + + + +
+ Category +
+
+ +
+
+
+ +
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 @@ + + + + + + + + + + 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 @@ + + + + +
+ + +
+
+ +
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 @@ + + + + +
+ + +
+
+ +
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 @@ + + + + + + + + + + + + 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 @@ + + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+
+ +
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 @@ + + + + +
+
+
+ Total: + + + +
+ Taxes: + + + +
+
+
+
+
+ +
\ 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 @@ + + + + +
+
+
+ +
+ +

Your shopping cart is empty

+
+
+ +
    + + + +
+ +
+
+
+
+
+ +
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 @@ + + + + +
  • + + + + + + + + + + + + + + + +
      + +
    • + + + + + at + + + + + + + + + + / + +
    • +
      + +
    • + With a + + % + + discount +
    • +
      +
    +
  • +
    + +
    \ 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 @@ + + + + +
    +
    + + + + +
    +
    + +
    +
    +
    + +
    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 @@ + + + + +
    +
    +
    + + + +
    +
    +
    + +

    + No results found for " + + ". +

    +
    + +

    There are no products in this category.

    +
    +
    +
    +
    +
    +
    + +
    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 @@ + + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +
    + +
    +
    +
    + +
    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 @@ + + + + + + + + + + + + + + +
    + +
    +
    + +
    +
    +
    + +
    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 @@ + + + + +
    +
    + + + + +
    + + +
    +
    +
    + + + + + + + + +
    +
    +
    +
    +
    +
    + +
    \ 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 @@ + + + + +
    + + +
    +
    + +

    + +

    +
    +
    +
    + +
    +
    + +
    Tel:
    +
    + +
    VAT:
    +
    + +
    +
    + +
    +
    + + + + +
    +
    + +
    +
    --------------------------------
    +
    Served by
    +
    +
    +
    +

    + + + +
    + + +
    + + +
    + +
    + +
    + + +
    + + -> + +
    +
    + +
    + + + + + + +
    +
    + +
    + Discount: % +
    +
    +
    + + + x + + + + +
    + + +
    + + + + +
    --------
    +
    +
    Subtotal
    + +
    + + +
    +
    +
    + + +
    --------
    +
    +
    + TOTAL + +
    + +
    + ROUNDED + +
    +
    +

    + + + + +
    + + +
    +
    +
    + +
    + CHANGE + +
    +
    + + + + +
    + Discounts + +
    +
    + + +
    + + +
    +
    +
    + Total Taxes + +
    +
    + + +
    + + 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 @@ + + + + +
    +
    +
    +

    + Change: + +

    + + Next Order + +
    +
    + +
    + Print Receipt +
    +
    + +
    +
    +
    +
    +
    + +
    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 @@ + + + + + + + + + + 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 @@ + + + + +
    +
    +
    + + + Back + +

    + +

    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + Order + +
    +
    +
    +
    +
    + +
    \ 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 @@ + + + + + + Run Point of Sale JS Tests + + + + 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 @@ - - - - -
    - -
    - -
    -
    -
    - -
    -
    - - -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    - -
    - -
    -
    -

    Loading

    -
    -
    -
    - -
    -
    - -
    -
    - - -
    - 0 -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - -
    -
    -
    - - -
    - -
    - -
    -
    - -
    -
    - -
    -
    -
    - - -
    - - - - - - -
    -
    - - -
    There are pending operations that could not be saved into the database, are you sure you want to exit?
    -
    - - -
    - -
    -
    - - -
    - -
    -
    - - -
    - -
    -
    - - -
    - - -
    -
    - - -
    - - - - -
    - - - - -
    - - - - -
    - - - - -
    -
    - - - -
    - Category -
    -
    - -
    -
    -
    - - - - - - - - -
    -
    - - -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    -
    - -
    - -

    No results found for "".

    -
    - -

    There are no products in this category.

    -
    -
    -
    -
    - -
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - -
    -
    -
    - - - - - - - - - - -
    - -
    -
    - -
    -
    -
    -
    -
    - - -
    -
    -
    - - - Back - -

    -
    -
    -
    - -
    -
    - -
    -
    - 123.14 € -
    -
    - Order - -
    -
    -
    -
    -
    - - - - - - - - - - -
    -
    - - - - - Partner - - -
    - -
    -
    -
    -
    -
    -
    -
    - Street - -
    -
    - City - -
    -
    - Postcode - -
    -
    - State - -
    -
    - Country - -
    -
    -
    - Language - -
    -
    -
    - Email - -
    -
    - Phone - -
    -
    - Barcode - -
    -
    - Tax ID - -
    -
    - Pricelist - -
    -
    -
    -
    -
    - -
    -
    - Partner logo -
    -
    -
    -
    -
    -
    -
    -
    - Address - - - - - N/A - -
    -
    - Email - - - - - N/A - -
    -
    - Phone - - - - - N/A - -
    -
    -
    -
    - Barcode - - - - - N/A - -
    -
    - Tax ID - - - - - N/A - -
    -
    - Pricelist - - - - - N/A - -
    -
    -
    -
    -
    - - -
    -
    -
    - - - Cancel - - - - - - - - - - - - Select Customer - - -
    -
    -
    - -
    -
    -
    - - - - - - - - - - -
    NameAddressPhone
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    - -
    - Payment request pending -
    -
    - Send -
    -
    - -
    - Transaction cancelled -
    -
    - Retry -
    -
    - -
    - Connection error -
    -
    - Force done -
    -
    - -
    - Waiting for card -
    -
    - Cancel -
    -
    - -
    - Request sent -
    -
    - -
    -
    - -
    - Reversal request sent to terminal -
    -
    - -
    -
    - - -
    - Payment Successful -
    -
    - Reverse -
    -
    - -
    - Payment Successful -
    -
    -
    -
    - -
    -
    - Payment reversed -
    -
    -
    -
    -
    -
    - - - -
    -
    - -
    -
    - Please select a payment method. -
    -
    -
    - - -
    -
    -
    - Remaining - - - -
    -
    - Total Due - - - -
    -
    -
    -
    - Change - - - -
    -
    -
    -
    -
    -
    -
    Tendered
    -
    Method
    -
    -
    -
    - - -
    - -
    - -
    -
    - -
    - -
    -
    -
    - -
    - - -
    - -
    -
    -
    - - - - - -
    -
    -
    - -
    - - -
    - -
    -
    -
    -
    - -
    -
    -
    - -
    - - -
    - - - - -
    - - - - -
    - - - - -
    - - - - -
    -
    - - -
    - -
    - -
    -
    -
    -
    - - -
    -
    -
    - - - Back - -

    Payment

    - - Validate - - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - - - - - - - Customer - - -
    - - -
    - Invoice -
    -
    -
    -
    - Email -
    - -
    - Tip -
    -
    - -
    - Open Cashbox -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -

    Change: 0.00

    - - Next Order - - -
    -
    - -
    - Print Receipt -
    -
    -
    -
    -
    -
    -
    - - - - -
    - - -
    -
    - -

    - -

    -
    -
    -
    - -
    -
    - -
    Tel:
    -
    - -
    VAT:
    -
    - -
    -
    - -
    -
    - - - - -
    -
    - -
    -
    --------------------------------
    -
    Served by
    -
    -
    -
    -

    - - - -
    - - - -
    - -
    ->
    -
    - -
    - - - - - - -
    -
    - -
    - Discount: % -
    -
    -
    - - - x - - - - -
    -
    - -
    - - - - - -
    --------
    -
    -
    Subtotal
    - -
    - - -
    -
    -
    - - -
    --------
    -
    -
    - TOTAL - -
    - -
    - ROUNDED - -
    -
    -

    - - - - -
    - - -
    -
    -
    - -
    - CHANGE - -
    -
    - - - - -
    - Discounts - -
    -
    - - -
    - - -
    -
    -
    - Total Taxes - -
    -
    - - -
    - - -
    - - -
    -
    - -

    -
    - -

    - - - -
    - -
    - - - - - - - x - - -
    - -
    Discount: %
    -
    -
    -
    - -
    -
    ------------------------
    -
    - -
    - Payments: -
    -
    - - -
    - -
    -
    ------------------------
    -
    - -
    - Taxes: -
    -
    - - -
    - -
    -
    ------------------------
    -
    - -
    - Total: - -
    - -
    -
    -
    -
    - -

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - Product image - - - - - - - - - - -
    -
    - -
    -
    -
    - - -
  • - - - - - - - - - - -
      - -
    • - - - - - at - - - - - - - - - - / - -
    • -
      - -
    • - With a - - % - - discount -
    • -
      -
    -
  • -
    - - -
    -
    -
    - -
    - -

    Your shopping cart is empty

    -
    -
    - -
      -
      -
      -
      - Total: 0.00 € -
      Taxes: 0.00€
      -
      -
      -
      -
      -
      -
      -
      -
      - - - - -
      -

      Debug Window

      - -
      -

      Electronic Scale

      -
        -
      • -
      • Set Weight
      • -
      • Reset
      • -
      - -

      Barcode Scanner

      -
        -
      • -
      • Scan
      • -
      • Scan EAN-13
      • -
      - -

      Orders

      - - - -

      Hardware Status

      -
        -
      • Weighing
      • -
      • Refresh Display
      • -
      -

      Hardware Events

      -
        -
      • Open Cashbox
      • - -
      • Read Weighing Scale
      • -
      -
      -
      -
      - - - -
      -
      - -
      - - - Delete - -
      -
      - - - - - - - - - Delete - - - - - -
      - - - - - - - - - - - - - - - - - - - - - - - - - - -
      -
      - - - - - - - - - -
      -
        -
      • `~
      • -
      • 1!
      • -
      • 2@
      • -
      • 3#
      • -
      • 4$
      • -
      • 5%
      • -
      • 6^
      • -
      • 7&
      • -
      • 8*
      • -
      • 9(
      • -
      • 0)
      • -
      • -_
      • -
      • =+
      • -
      • delete
      • -
      • tab
      • -
      • q
      • -
      • w
      • -
      • e
      • -
      • r
      • -
      • t
      • -
      • y
      • -
      • u
      • -
      • i
      • -
      • o
      • -
      • p
      • -
      • [{
      • -
      • ]}
      • -
      • \|
      • -
      • caps lock
      • -
      • a
      • -
      • s
      • -
      • d
      • -
      • f
      • -
      • g
      • -
      • h
      • -
      • j
      • -
      • k
      • -
      • l
      • -
      • ;:
      • -
      • '"
      • -
      • return
      • -
      • shift
      • -
      • z
      • -
      • x
      • -
      • c
      • -
      • v
      • -
      • b
      • -
      • n
      • -
      • m
      • -
      • ,<
      • -
      • .>
      • -
      • /?
      • -
      • shift
      • -
      • &nbsp;
      • -
      -

      close

      -
      -
      - - -
      -
        -
      • q1
      • -
      • w2
      • -
      • e3
      • -
      • r4
      • -
      • t5
      • -
      • y6
      • -
      • u7
      • -
      • i8
      • -
      • o9
      • -
      • p0
      • - -
      • a@
      • -
      • s#
      • -
      • d%
      • -
      • f*
      • -
      • g/
      • -
      • h-
      • -
      • j+
      • -
      • k(
      • -
      • l)
      • - -
      • z?
      • -
      • x!
      • -
      • c"
      • -
      • v'
      • -
      • b:
      • -
      • n;
      • -
      • m,
      • -
      • delete
      • - -
      • 123ABC
      • -
      • &nbsp;
      • -
      • ..
      • -
      • return
      • -
      -

      close

      -
      -
      - - -
      - - - -
      -
      - - - -
      -
      -
      -
      -
      -
      - - - - - -
      - -
      -
      - -
      -
      - -
      - Change: -
      -
      - -
      -
      -
      - 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 */ ` +
      + `; + 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 */ ` +
      + `; + + 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 */ ` +
      + + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + + UseSlot + +
      + `; + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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 */ ` +
      + +
      + `; + + 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` +
      + +
      + `; + } + 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 similarity index 63% rename from addons/point_of_sale/views/point_of_sale.xml rename to 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 @@ -