summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static/src')
-rw-r--r--addons/point_of_sale/static/src/css/chrome50.css6
-rw-r--r--addons/point_of_sale/static/src/css/customer_facing_display.css730
-rw-r--r--addons/point_of_sale/static/src/css/keyboard.css153
-rw-r--r--addons/point_of_sale/static/src/css/pos.css3555
-rw-r--r--addons/point_of_sale/static/src/css/pos_receipts.css65
-rw-r--r--addons/point_of_sale/static/src/fonts/Inconsolata.otfbin0 -> 58464 bytes
-rw-r--r--addons/point_of_sale/static/src/img/backspace.pngbin0 -> 5970 bytes
-rw-r--r--addons/point_of_sale/static/src/img/bc-arrow-big.pngbin0 -> 315 bytes
-rw-r--r--addons/point_of_sale/static/src/img/bc-arrow.pngbin0 -> 361 bytes
-rw-r--r--addons/point_of_sale/static/src/img/blocks/block_simple_text.pngbin0 -> 329 bytes
-rw-r--r--addons/point_of_sale/static/src/img/default_category_photo.pngbin0 -> 1362 bytes
-rw-r--r--addons/point_of_sale/static/src/img/home.pngbin0 -> 2924 bytes
-rw-r--r--addons/point_of_sale/static/src/img/ios-share-icon.pngbin0 -> 522 bytes
-rw-r--r--addons/point_of_sale/static/src/img/logo.pngbin0 -> 4440 bytes
-rw-r--r--addons/point_of_sale/static/src/img/pos_screenshot.jpgbin0 -> 77204 bytes
-rw-r--r--addons/point_of_sale/static/src/img/scroll-down.pngbin0 -> 357 bytes
-rw-r--r--addons/point_of_sale/static/src/img/scroll-up.pngbin0 -> 303 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon-128.pngbin0 -> 7118 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon-196.pngbin0 -> 6442 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon-ipad-retina.pngbin0 -> 3441 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon-ipad.pngbin0 -> 4098 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon-iphone-retina.pngbin0 -> 8403 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon-iphone.pngbin0 -> 3745 bytes
-rw-r--r--addons/point_of_sale/static/src/img/touch-icon.svg199
-rw-r--r--addons/point_of_sale/static/src/js/Chrome.js454
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js23
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js87
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js161
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js36
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js36
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js91
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js38
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js37
-rw-r--r--addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js41
-rw-r--r--addons/point_of_sale/static/src/js/ClassRegistry.js262
-rw-r--r--addons/point_of_sale/static/src/js/ComponentRegistry.js29
-rw-r--r--addons/point_of_sale/static/src/js/ControlButtonsMixin.js84
-rw-r--r--addons/point_of_sale/static/src/js/Gui.js60
-rw-r--r--addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js62
-rw-r--r--addons/point_of_sale/static/src/js/Misc/Draggable.js142
-rw-r--r--addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js23
-rw-r--r--addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js39
-rw-r--r--addons/point_of_sale/static/src/js/Misc/NotificationSound.js19
-rw-r--r--addons/point_of_sale/static/src/js/Misc/NumberBuffer.js297
-rw-r--r--addons/point_of_sale/static/src/js/Misc/SearchBar.js115
-rw-r--r--addons/point_of_sale/static/src/js/PopupControllerMixin.js44
-rw-r--r--addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js60
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js20
-rw-r--r--addons/point_of_sale/static/src/js/Popups/EditListInput.js19
-rw-r--r--addons/point_of_sale/static/src/js/Popups/EditListPopup.js105
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js26
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ErrorPopup.js24
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js44
-rw-r--r--addons/point_of_sale/static/src/js/Popups/NumberPopup.js79
-rw-r--r--addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js29
-rw-r--r--addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js27
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js89
-rw-r--r--addons/point_of_sale/static/src/js/Popups/SelectionPopup.js57
-rw-r--r--addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js39
-rw-r--r--addons/point_of_sale/static/src/js/Popups/TextInputPopup.js34
-rw-r--r--addons/point_of_sale/static/src/js/PosComponent.js59
-rw-r--r--addons/point_of_sale/static/src/js/PosContext.js12
-rw-r--r--addons/point_of_sale/static/src/js/Registries.js11
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js129
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js17
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js182
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js155
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js36
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js29
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js214
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js31
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js124
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js101
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js42
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js55
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js32
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js17
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js376
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js23
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js23
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js30
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js42
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js80
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js67
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js47
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js59
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js110
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js49
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js327
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js88
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js33
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js47
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js123
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js102
-rw-r--r--addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js220
-rw-r--r--addons/point_of_sale/static/src/js/barcode_reader.js158
-rw-r--r--addons/point_of_sale/static/src/js/custom_hooks.js149
-rw-r--r--addons/point_of_sale/static/src/js/db.js556
-rw-r--r--addons/point_of_sale/static/src/js/debug_manager.js20
-rw-r--r--addons/point_of_sale/static/src/js/devices.js492
-rw-r--r--addons/point_of_sale/static/src/js/keyboard.js207
-rw-r--r--addons/point_of_sale/static/src/js/main.js49
-rw-r--r--addons/point_of_sale/static/src/js/models.js3514
-rw-r--r--addons/point_of_sale/static/src/js/payment.js95
-rw-r--r--addons/point_of_sale/static/src/js/printers.js172
-rw-r--r--addons/point_of_sale/static/src/js/tours/point_of_sale.js31
-rw-r--r--addons/point_of_sale/static/src/js/utils.js49
-rw-r--r--addons/point_of_sale/static/src/scss/customer_facing_display.scss475
-rw-r--r--addons/point_of_sale/static/src/scss/pos_dashboard.scss5
-rw-r--r--addons/point_of_sale/static/src/sounds/bell.wavbin0 -> 17684 bytes
-rw-r--r--addons/point_of_sale/static/src/sounds/error.wavbin0 -> 25190 bytes
-rw-r--r--addons/point_of_sale/static/src/xml/Chrome.xml136
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml19
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml93
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml29
-rw-r--r--addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/Misc/Draggable.xml8
-rw-r--r--addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml22
-rw-r--r--addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml8
-rw-r--r--addons/point_of_sale/static/src/xml/Misc/SearchBar.xml44
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml27
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/EditListInput.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml22
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml36
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml65
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml39
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml90
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml29
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/SaleDetailsReport.xml75
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml119
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml30
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml90
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml30
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml35
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml20
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml36
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml32
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml15
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml21
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml26
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml18
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml13
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml99
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml76
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml31
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml62
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml43
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml25
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml15
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml15
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml12
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml22
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml43
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml23
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml26
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml75
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml21
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml28
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml39
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml11
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml51
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml211
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml47
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml10
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml36
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml60
-rw-r--r--addons/point_of_sale/static/src/xml/debug_manager.xml10
193 files changed, 19426 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/src/css/chrome50.css b/addons/point_of_sale/static/src/css/chrome50.css
new file mode 100644
index 00000000..67ecddcc
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/chrome50.css
@@ -0,0 +1,6 @@
+.pos .screen .content-cell{
+ height: 100%;
+}
+.pos .subwindow .subwindow-container{
+ height: 100%;
+} \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/css/customer_facing_display.css b/addons/point_of_sale/static/src/css/customer_facing_display.css
new file mode 100644
index 00000000..824d220b
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/customer_facing_display.css
@@ -0,0 +1,730 @@
+@keyframes item_in {
+ 0% {
+ opacity: 0;
+ margin-top: -30px;
+ }
+ 50% {
+ margin-top: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+@-webkit-keyframes item_in {
+ 0% {
+ opacity: 0;
+ margin-top: -30px;
+ }
+ 50% {
+ margin-top: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+body {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+ font-smooth: always;
+}
+body .pos-customer_facing_display {
+ background-color: #f6f6f6;
+ font-size: 2vw;
+ font-family: Futura, HelveticaNeue, Helvetica, Arial, "Lucida Grande", sans-serif;
+ font-weight: 300;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ -moz-flex-direction: row;
+ -ms-flex-direction: row;
+ -o-flex-direction: row;
+ flex-direction: row;
+}
+body .pos-customer_facing_display .pos-customer_products,
+body .pos-customer_facing_display .pos-payment_info {
+ height: 100%;
+ padding: 2%;
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ -moz-flex-direction: column;
+ -ms-flex-direction: column;
+ -o-flex-direction: column;
+ flex-direction: column;
+ -webkit-box-flex: 1;
+ -webkit-flex-grow: 1;
+ -moz-box-flex: 1;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+}
+body .pos-customer_facing_display .pos_orderlines {
+ width: 100%;
+ height: 100%;
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ -moz-flex-direction: column;
+ -ms-flex-direction: column;
+ -o-flex-direction: column;
+ flex-direction: column;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_list {
+ overflow-y: scroll;
+ padding-right: 1.5vw;
+ position: relative;
+ height: 100%;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item {
+ margin-bottom: 1vw;
+ padding: 1%;
+ border-radius: 0.3vw;
+ height: auto;
+ -webkit-box-flex: 0 1 auto;
+ -webkit-flex: 0 1 auto;
+ -moz-box-flex: 0 1 auto;
+ -ms-flex: 0 1 auto;
+ flex: 0 1 auto;
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ -moz-flex-direction: row;
+ -ms-flex-direction: row;
+ -o-flex-direction: row;
+ flex-direction: row;
+ -webkit-box-align: center;
+ -webkit-align-items: center;
+ -moz-box-align: center;
+ -ms-flex-align: center;
+ -ms-grid-row-align: center;
+ align-items: center;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item:last-of-type {
+ animation: item_in 1s ease;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header {
+ background-color: transparent;
+ box-shadow: none;
+ animation: none;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div, body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child {
+ border-left-width: 0;
+ text-align: center;
+ font-size: 70%;
+ font-weight: normal;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child {
+ text-align: left;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div {
+ width: 5%;
+ text-align: left;
+ margin-right: 4%;
+ font-size: 80%;
+ -webkit-box-flex: 1;
+ -webkit-flex-grow: 1;
+ -moz-box-flex: 1;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:first-child {
+ margin-right: 2%;
+ -webkit-box-flex: 1 1 1%;
+ -webkit-flex: 1 1 1%;
+ -moz-box-flex: 1 1 1%;
+ -ms-flex: 1 1 1%;
+ flex: 1 1 1%;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:nth-child(2) {
+ width: 40%;
+ border-left: 1px solid;
+ padding-left: 2%;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:nth-child(3) {
+ text-align: center;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:last-child {
+ margin-right: 0;
+ font-weight: bold;
+}
+body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div div {
+ background-position: center;
+ background-size: cover;
+ padding-top: 75%;
+ display: block;
+}
+body .pos-customer_facing_display .pos-payment_info {
+ max-width: 30%;
+ padding: 2% 2% 1% 2%;
+ -webkit-flex-direction: column;
+ -moz-flex-direction: column;
+ -ms-flex-direction: column;
+ -o-flex-direction: column;
+ flex-direction: column;
+ -webkit-box-pack: space-between;
+ -webkit-justify-content: space-between;
+ -moz-box-pack: space-between;
+ -ms-flex-pack: space-between;
+ justify-content: space-between;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-adv,
+body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
+ background-position: center top;
+ background-size: contain;
+ background-repeat: no-repeat;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-adv[style*="url(http://placehold.it"],
+body .pos-customer_facing_display .pos-payment_info .pos-company_logo[style*="url(http://placehold.it"] {
+ background-color: #ccc;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
+ background-image: url(/logo);
+ margin-bottom: 10%;
+ -webkit-box-flex: 0 0 20%;
+ -webkit-flex: 0 0 20%;
+ -moz-box-flex: 0 0 20%;
+ -ms-flex: 0 0 20%;
+ flex: 0 0 20%;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-adv {
+ margin-bottom: 5%;
+ border-bottom: 10px solid transparent;
+ box-shadow: 0 1px rgba(246, 246, 246, 0.2);
+ -webkit-box-flex: 1 1 60%;
+ -webkit-flex: 1 1 60%;
+ -moz-box-flex: 1 1 60%;
+ -ms-flex: 1 1 60%;
+ flex: 1 1 60%;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total,
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines {
+ -webkit-flex-direction: row;
+ -moz-flex-direction: row;
+ -ms-flex-direction: row;
+ -o-flex-direction: row;
+ flex-direction: row;
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+ -webkit-flex-wrap: wrap;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -webkit-box-pack: space-between;
+ -webkit-justify-content: space-between;
+ -moz-box-pack: space-between;
+ -ms-flex-pack: space-between;
+ justify-content: space-between;
+ -webkit-box-align: baseline;
+ -webkit-align-items: baseline;
+ -moz-box-align: baseline;
+ -ms-flex-align: baseline;
+ -ms-grid-row-align: baseline;
+ align-items: baseline;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total > div,
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines > div {
+ -webkit-box-flex: 1 0 48%;
+ -webkit-flex: 1 0 48%;
+ -moz-box-flex: 1 0 48%;
+ -ms-flex: 1 0 48%;
+ flex: 1 0 48%;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total > div:nth-child(even),
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines > div:nth-child(even) {
+ font-weight: bold;
+ font-size: 120%;
+ margin-right: 0;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total {
+ font-size: 2vw;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines {
+ margin-top: 2%;
+ font-size: 1.5vw;
+ line-height: 1.3;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container {
+ text-align: right;
+ margin-top: 10%;
+ -webkit-box-flex: 0 1 auto;
+ -webkit-flex: 0 1 auto;
+ -moz-box-flex: 0 1 auto;
+ -ms-flex: 0 1 auto;
+ flex: 0 1 auto;
+}
+body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container img {
+ max-width: 40px;
+}
+@media all and (orientation: portrait) {
+ body .pos-customer_facing_display {
+ font-size: 2vh;
+ height: 100%;
+ -webkit-flex-direction: column;
+ -moz-flex-direction: column;
+ -ms-flex-direction: column;
+ -o-flex-direction: column;
+ flex-direction: column;
+ }
+ body .pos-customer_facing_display:before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 17vh;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-adv {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 15vh;
+ width: 99vw;
+ margin: 0.5vh;
+ border-width: 0;
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+ }
+ body .pos-customer_facing_display.pos-js_no_ADV:before {
+ display: none;
+ }
+ body .pos-customer_facing_display.pos-js_no_ADV .pos-customer_products {
+ padding-top: 0;
+ }
+ body .pos-customer_facing_display .pos-customer_products {
+ padding-top: 17vh;
+ height: 72vw;
+ overflow: hidden;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines {
+ -webkit-box-flex: 1 0 auto;
+ -webkit-flex: 1 0 auto;
+ -moz-box-flex: 1 0 auto;
+ -ms-flex: 1 0 auto;
+ flex: 1 0 auto;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_item > div:nth-child(2) {
+ width: 30%;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_item.pos_orderlines_header div {
+ font-size: 90%;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list {
+ padding-right: 1.5vh;
+ height: auto;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item {
+ box-shadow: 0 0.1vh 0.1vh #dddddd;
+ margin-bottom: 1vh;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item > div {
+ font-size: 100%;
+ }
+ body .pos-customer_facing_display .pos-payment_info {
+ max-width: 100%;
+ overflow: hidden;
+ padding-top: 0;
+ min-height: 120px;
+ -webkit-box-flex: 0 1 23vw;
+ -webkit-flex: 0 1 23vw;
+ -moz-box-flex: 0 1 23vw;
+ -ms-flex: 0 1 23vw;
+ flex: 0 1 23vw;
+ -webkit-flex-direction: row;
+ -moz-flex-direction: row;
+ -ms-flex-direction: row;
+ -o-flex-direction: row;
+ flex-direction: row;
+ -webkit-box-align: center;
+ -webkit-align-items: center;
+ -moz-box-align: center;
+ -ms-flex-align: center;
+ -ms-grid-row-align: center;
+ align-items: center;
+ -webkit-box-pack: space-between;
+ -webkit-justify-content: space-between;
+ -moz-box-pack: space-between;
+ -ms-flex-pack: space-between;
+ justify-content: space-between;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
+ margin: 0;
+ background-position: left center;
+ margin-right: 5%;
+ height: 100%;
+ padding: 0;
+ -webkit-box-flex: 1 1 20%;
+ -webkit-flex: 1 1 20%;
+ -moz-box-flex: 1 1 20%;
+ -ms-flex: 1 1 20%;
+ flex: 1 1 20%;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details {
+ -webkit-box-flex: 0 1 50%;
+ -webkit-flex: 0 1 50%;
+ -moz-box-flex: 0 1 50%;
+ -ms-flex: 0 1 50%;
+ flex: 0 1 50%;
+ -webkit-flex-direction: column;
+ -moz-flex-direction: column;
+ -ms-flex-direction: column;
+ -o-flex-direction: column;
+ flex-direction: column;
+ min-width: 170px;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total {
+ font-size: 3vw;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total .pos_total-amount {
+ font-size: 3.5vw;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines {
+ margin-top: 2%;
+ font-size: 80%;
+ line-height: 1.2;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container {
+ position: absolute;
+ right: 3%;
+ bottom: 1%;
+ }
+}
+@media all and (orientation: portrait) and (max-width: 340px) {
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list {
+ padding-right: 0;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item > div {
+ font-size: 70%;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_header > div {
+ font-size: 60%;
+ }
+ body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_header > div:last-child {
+ text-align: center;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
+ display: none !important;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details {
+ -webkit-box-flex: 1 0 100%;
+ -webkit-flex: 1 0 100%;
+ -moz-box-flex: 1 0 100%;
+ -ms-flex: 1 0 100%;
+ flex: 1 0 100%;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total {
+ font-size: 6vw;
+ }
+ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total .pos_total-amount {
+ font-size: 6.5vw;
+ }
+}
+
+body .pos-hidden {
+ opacity: 0;
+}
+
+.pos-palette_01 .pos-payment_info {
+ background: #3E3E3E;
+ color: #f6f6f6;
+}
+.pos-palette_01 .pos-customer_products {
+ background: #f6f6f6;
+ color: #585858;
+}
+.pos-palette_01 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #aaaaaa;
+}
+.pos-palette_01 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_01:before {
+ background: #3E3E3E;
+ }
+}
+
+.pos-palette_02 .pos-payment_info {
+ background: #364152;
+ color: #e6e7e8;
+}
+.pos-palette_02 .pos-customer_products {
+ background: #ecf2f6;
+ color: #364152;
+}
+.pos-palette_02 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #364152;
+}
+.pos-palette_02 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_02:before {
+ background: #364152;
+ }
+}
+
+.pos-palette_03 .pos-payment_info {
+ background: #1BA39C;
+ color: #f6f6f6;
+}
+.pos-palette_03 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_03 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_03 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_03:before {
+ background: #1BA39C;
+ }
+}
+
+.pos-palette_04 .pos-payment_info {
+ background: #0b7b6c;
+ color: #f6f6f6;
+}
+.pos-palette_04 .pos-customer_products {
+ background: #efeeec;
+ color: #585858;
+}
+.pos-palette_04 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a9a499;
+}
+.pos-palette_04 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_04:before {
+ background: #0b7b6c;
+ }
+}
+
+.pos-palette_05 .pos-payment_info {
+ background: #E26868;
+ color: #f6f6f6;
+}
+.pos-palette_05 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_05 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_05 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_05:before {
+ background: #E26868;
+ }
+}
+
+.pos-palette_06 .pos-payment_info {
+ background: #9E373B;
+ color: #f6f6f6;
+}
+.pos-palette_06 .pos-customer_products {
+ background: #f6f6f6;
+ color: #585858;
+}
+.pos-palette_06 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #aaaaaa;
+}
+.pos-palette_06 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_06:before {
+ background: #9E373B;
+ }
+}
+
+.pos-palette_07 .pos-payment_info {
+ background: #ce9934;
+ color: white;
+}
+.pos-palette_07 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_07 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_07 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_07:before {
+ background: #ce9934;
+ }
+}
+
+.pos-palette_08 .pos-payment_info {
+ background: #a48c77;
+ color: #f6f6f6;
+}
+.pos-palette_08 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_08 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_08 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_08:before {
+ background: #a48c77;
+ }
+}
+
+.pos-palette_09 .pos-payment_info {
+ background: linear-gradient(30deg, #014d43, #127e71);
+ color: #f6f6f6;
+}
+.pos-palette_09 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_09 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_09 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_09:before {
+ background: linear-gradient(30deg, #014d43, #127e71);
+ }
+}
+
+.pos-palette_10 .pos-payment_info {
+ background: linear-gradient(30deg, #e2316c, #ea4c89);
+ color: white;
+}
+.pos-palette_10 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_10 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_10 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_10:before {
+ background: linear-gradient(30deg, #e2316c, #ea4c89);
+ }
+}
+
+.pos-palette_11 .pos-payment_info {
+ background: linear-gradient(30deg, #362b3d, #5b4a63);
+ color: white;
+}
+.pos-palette_11 .pos-customer_products {
+ background: #ececec;
+ color: #585858;
+}
+.pos-palette_11 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: white;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #a0a0a0;
+}
+.pos-palette_11 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_11:before {
+ background: linear-gradient(30deg, #362b3d, #5b4a63);
+ }
+}
+
+.pos-palette_12 .pos-payment_info {
+ background: #434343;
+ color: #e6e6e6;
+}
+.pos-palette_12 .pos-customer_products {
+ background: #5b5b5b;
+ color: #bdb9b9;
+}
+.pos-palette_12 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: #f5f5f5;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #0f0f0f;
+}
+.pos-palette_12 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_12:before {
+ background: #434343;
+ }
+}
+
+.pos-palette_13 .pos-payment_info {
+ background: linear-gradient(30deg, #1a1b1f, #3d3f45);
+ color: white;
+}
+.pos-palette_13 .pos-customer_products {
+ background: #a2a2ab;
+ color: #f6f6f6;
+}
+.pos-palette_13 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
+ background-color: #f6f6f6;
+ color: #3E3E3E;
+ box-shadow: 0 0.1vh 0.1vh #55555f;
+}
+.pos-palette_13 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
+ border-color: rgba(62, 62, 62, 0.3);
+}
+@media all and (orientation: portrait) {
+ .pos-palette_13:before {
+ background: linear-gradient(30deg, #1a1b1f, #3d3f45);
+ }
+}
diff --git a/addons/point_of_sale/static/src/css/keyboard.css b/addons/point_of_sale/static/src/css/keyboard.css
new file mode 100644
index 00000000..ee5fcfe3
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/keyboard.css
@@ -0,0 +1,153 @@
+/* Onscreen Keyboard http://net.tutsplus.com/tutorials/javascript-ajax/creating-a-keyboard-with-css-and-jquery/ */
+
+/*rtl:ignore*/
+.pos .keyboard_frame{
+ display: none;
+ position:absolute;
+ left: 0;
+ bottom: 0px;
+ margin: 0;
+ padding: 0;
+ padding-top: 15px;
+ width: 100%;
+ height: 0px; /* 235px, animated via jquery */
+ background-color: #BBB;
+ overflow:hidden;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ z-index:10000;
+}
+.pos .keyboard_frame .close_button{
+ height:40px;
+ width:60px;
+ text-align:center;
+ background-color: #DDD;
+ font-size: 12px;
+ line-height:40px;
+ -webkit-border-radius: 5px;
+ position:absolute;
+ top:0;
+ right:15px;
+ cursor: pointer;
+}
+/*rtl:ignore*/
+.pos .keyboard li {
+ float: left;
+ text-align: center;
+ background-color: #fff;
+ border: 1px solid #f0f0f0;
+ top:0;
+ cursor: pointer;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ -webkit-transition-property: top, background-color;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: linear;
+}
+/*rtl:ignore*/
+.pos .keyboard li:active{
+ position: relative;
+ top: 2px;
+ left: 0px;
+ border-color: #ddd;
+ background-color:#e5e5e5;
+ cursor: pointer;
+ -webkit-transition-property: top, background-color;
+ -webkit-transition-duration: 0.1s;
+ -webkit-transition-timing-function: ease-out;
+}
+.pos .uppercase {
+ text-transform: uppercase;
+}
+.pos .on {
+ display: none;
+}
+/*rtl:ignore*/
+.pos .firstitem{
+ clear: left;
+}
+/*rtl:ignore*/
+.pos .keyboard .lastitem {
+ margin-right: 0 !important;
+}
+
+/* ---- full sized keyboard ---- */
+
+.pos .full_keyboard {
+ list-style: none;
+ font-size: 14px;
+ width: 685px;
+ height: 100%;
+ margin-left: auto !important;
+ margin-right: auto !important;
+}
+/*rtl:ignore*/
+.pos .full_keyboard li{
+ margin: 0 5px 5px 0 !important;
+ width: 40px;
+ height: 40px;
+ line-height: 40px;
+}
+.pos .full_keyboard .tab, .pos .full_keyboard .delete {
+ width: 70px;
+}
+.pos .full_keyboard .capslock {
+ width: 80px;
+}
+.pos .full_keyboard .return {
+ width: 77px;
+}
+.pos .full_keyboard .left-shift {
+ width: 95px;
+}
+.pos .full_keyboard .right-shift {
+ width: 109px;
+}
+
+/*rtl:ignore*/
+.pos .full_keyboard .space {
+ clear: left;
+ width: 673px;
+}
+
+/* ---- simplified keyboard ---- */
+
+.pos .simple_keyboard {
+ list-style: none;
+ font-size: 16px;
+ width: 555px;
+ height: 220px;
+ margin-left: auto !important;
+ margin-right: auto !important;
+}
+/*rtl:ignore*/
+.pos .simple_keyboard li{
+ margin: 0 5px 5px 0 !important;
+ width: 49px;
+ height: 49px;
+ line-height: 49px;
+}
+/*rtl:ignore*/
+.pos .simple_keyboard .firstitem.row_asdf{
+ margin-left:25px !important;
+}
+/*rtl:ignore*/
+.pos .simple_keyboard .firstitem.row_zxcv{
+ margin-left:55px !important;
+}
+.pos .simple_keyboard .delete{
+ width: 103px;
+}
+.pos .simple_keyboard .return{
+ width: 103px;
+}
+.pos .simple_keyboard .space{
+ width:273px;
+}
+.pos .simple_keyboard .numlock{
+ width:103px;
+}
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
new file mode 100644
index 00000000..b124326c
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -0,0 +1,3555 @@
+/* --- Fonts --- */
+
+@font-face{
+ font-family: 'Inconsolata';
+ src: url(../fonts/Inconsolata.otf);
+}
+
+/* --- Styling of OpenERP Elements --- */
+.ui-dialog, .modal-dialog {
+ background: white;
+ padding: 10px;
+ border-radius: 3px;
+ font-family: sans-serif;
+ box-shadow: 0px 10px 40px rgba(0,0,0,0.4);
+ position: absolute;
+ top: 30px;
+ height: 400px;
+ overflow: scroll;
+}
+.ui-dialog button, .modal-dialog button {
+ padding: 8px;
+ min-width: 48px;
+}
+.ui-dialog .ui-icon-closethick{
+ float: right;
+}
+div.modal.in {
+ position: absolute;
+ background: white;
+ padding: 20px;
+ box-shadow: 0px 10px 20px black;
+ border-radius: 3px;
+ max-width: 600px;
+ max-height: 400px;
+ margin-top: -200px;
+ margin-left: -300px;
+ top: 50%;
+ left: 50%;
+}
+
+/* --- Generic Restyling and Resets --- */
+
+html {
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+ font-family: sans-serif;
+}
+
+table {
+ border-spacing: 0px;
+ border-collapse: collapse;
+}
+td {
+ padding: 0px;
+}
+
+.oe_hidden{
+ display: none !important;
+}
+.oe_invisible{
+ visibility: hidden !important;
+}
+.clearfix:after {
+ content:" ";
+ display: block;
+ visibility: hidden;
+ line-height: 0;
+ height: 0;
+ clear: both;
+}
+
+
+.pos input::-webkit-outer-spin-button,
+.pos input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.pos button{
+ box-shadow: none;
+ outline: none;
+ border: none;
+ font-family: 'Lato';
+}
+.pos button:hover{
+ background: default;
+}
+.pos textarea {
+ font-family: "Lato";
+ font-size: 20px;
+ color: #444;
+ padding: 10px;
+ border-radius: 3px;
+ border: none;
+ box-shadow: 0px 0px 0px 1px rgb(220,220,220) inset;
+}
+.pos textarea:focus {
+ outline: none;
+ box-shadow: 0px 0px 0px 3px #6EC89B;
+}
+
+
+.pos .oe_hidden{
+ display: none !important;
+}
+
+.pos ul, .pos li {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+
+.pos {
+ direction: ltr;
+ padding: 0;
+ margin: 0;
+ background-color: #f0eeee;
+ font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial;
+ color: #555555;
+ font-size: 12px;
+ /* Some elements inside .pos are allowed to be moved/resized. When
+ * this 'move/resize' event is performed in touch devices, .pos
+ * element also tries to scroll (as the default action of touchstart+
+ * touchmove events).
+ * Position is fixed to prevent the movement of .pos element during
+ * the described touch events.
+ */
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ text-shadow: none;
+ overflow: hidden;
+}
+
+/* ********* The black loading screen ********* */
+
+.pos .loader{
+ background-color: #222;
+ position:absolute;
+ left:0px;
+ top:0px;
+ width:100%;
+ height:100%;
+ z-index: 999;
+ text-align: center;
+ font-family: Lato;
+ color: #555555;
+}
+
+.pos .loader-feedback{
+ width: 400px;
+ height: 160px;
+ margin: -60px -200px;
+ position: absolute;
+ left: 50%; top: 50%;
+ text-align: center;
+}
+.pos .loader-feedback h1{
+ font-weight: 300;
+}
+.pos .loader-feedback .progressbar{
+ background: rgb(73,73,73);
+ height: 1px;
+}
+.pos .loader-feedback .progressbar > .progress{
+ height: 100%;
+ background: white;
+ width: 0%;
+ box-shadow: 0px 0px 5px rgba(255,255,255,0.35);
+}
+.pos .loader-feedback .button{
+ display: inline-block;
+ margin: 25px auto;
+ line-height: 42px;
+ padding: 0px 16px;
+ font-size: 20px;
+ font-weight: 300;
+ border: solid 1px;
+ border-radius: 5px;
+ cursor: pointer;
+}
+.pos .loader-feedback .button:active{
+ color: #222;
+ background: #555555;
+}
+/* ********* Generic Layout Constructs ********* */
+
+.pos .window{
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: 100%;
+ display: table;
+ border: none;
+ overflow: hidden;
+}
+.pos .window .subwindow{
+ display: table-row;
+ width: 100%;
+ height: 100%;
+}
+.pos .window .subwindow.collapsed{
+ height: 0px;
+}
+.pos .window .subwindow-container .collapsed{
+ height: 0px;
+}
+.pos .subwindow .subwindow-container{
+ display: table-cell;
+ position: relative;
+}
+/* firefox seems to ignore the relative positionning of the subwindow-container
+ * putting this inside subwindow-container fixes it.
+ */
+.pos .subwindow .subwindow-container-fix{
+ height: 100%;
+ position: relative;
+}
+
+.pos .clientlist-screen .window,
+.pos .clientlist-screen .full-content .subwindow{
+ display: block;
+}
+.pos .clientlist-screen .full-content .subwindow-container{
+ display: block;
+ height: 100%;
+}
+.pos .clientlist-screen .full-content .subwindow.collapsed,
+.pos .clientlist-screen .full-content .subwindow-container.collapsed{
+ height: auto;
+}
+
+/* ---- Scrollers ----- */
+
+.pos .scroller-container{
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+}
+.pos .scroller{
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+.pos .scroller.horizontal{
+ overflow-y: hidden;
+ overflow-x: auto;
+}
+.pos .scroller-content{
+ -webkit-transform: translate3d(0,0,0);
+}
+.pos .scroller-container ::-webkit-scrollbar{
+ width: 10px;
+ height: 10px;
+}
+.pos .scroller-container ::-webkit-scrollbar-track{
+ background: rgb(224,224,224);
+ border-left: solid 1px rgb(200,200,200);
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+.pos .scroller-container ::-webkit-scrollbar-thumb{
+ background: rgb(168,168,168);
+ min-height: 30px;
+}
+
+/* ********* Generic element styling ********* */
+
+.pos a {
+ text-decoration: none;
+ color: #555555;
+}
+.pos button, .pos a.button {
+ display: inline-block;
+ cursor: pointer;
+ padding: 4px 10px;
+ font-size: 11px;
+ border: 1px solid #cacaca;
+ background: #e2e2e2;
+ border-radius: 3px;
+}
+.pos ul, .pos ol {
+ padding: 0;
+ margin: 0;
+}
+.pos li {
+ list-style-type: none;
+}
+.pos .pos-right-align {
+ text-align: right;
+}
+.pos .pos-center-align {
+ text-align: center;
+}
+.pos .pos-disc-font {
+ font-size: 12px;
+ font-style:italic;
+ color: #808080;
+}
+
+/* ********* The black header bar ********* */
+
+
+.pos .pos-topheader {
+ position:absolute;
+ left:0;
+ top:0;
+ width: 100%;
+ height: 48px;
+ margin:0;
+ padding:0;
+ color: gray;
+ background: #875A7B;
+ display: flex;
+}
+
+/* a) The left part of the top-bar */
+
+.pos .pos-branding{
+ min-width: 503px;
+ max-width: 503px;
+ flex-grow: 1;
+ height:100%;
+ margin:0;
+ padding:0;
+ text-align:left;
+ line-height:100%;
+ vertical-align: middle;
+ display: flex;
+ justify-content: space-between;
+}
+.pos .pos-logo {
+ height: 35px;
+ margin-left: 10px;
+ margin-top: 5px;
+ vertical-align:middle;
+}
+.pos .pos-branding .username{
+ float:right;
+ color:#DDD;
+ font-size:16px;
+ margin-right:32px;
+ line-height: 48px;
+ font-style:italic;
+ cursor: pointer;
+}
+
+.pos .ticket-button {
+ display: flex;
+}
+
+/* b) The right part of the top-bar */
+
+.pos .pos-rightheader {
+ flex-grow: 1;
+ height:100%;
+ display: flex;
+ overflow : hidden;
+ overflow-x: auto;
+}
+.pos .pos-rightheader > * {
+ border-right: 1px solid #875A7B;
+}
+
+.pos .status-buttons-portal {
+ flex: 1;
+}
+
+.pos .status-buttons {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1;
+}
+
+.pos .order-button{
+ color: #f0f0f0;
+ display: inline-block;
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ height: 46px;
+ padding: 4px 8px;
+ margin: 3px;
+ margin-bottom: 0px;
+ margin-right: 2px;
+ padding-top: 0px;
+ background: #8b8b8b;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ vertical-align: top;
+ line-height: 42px;
+ text-align: center;
+ box-shadow: 0px -5px 10px -6px rgb(82,82,82) inset;
+ cursor: pointer;
+ min-width: 45px;
+}
+
+.pos .order-button:first-child {
+ margin-left: 0px;
+}
+
+.pos .order-button.selected{
+ font-weight: 900;
+ background: #EEEEEE;
+ color: rgb(75,75,75);
+ height: 45px;
+ border-bottom: solid 1px rgb(196, 196, 196);
+ box-shadow: none;
+ -webkit-flex-shrink: 0;
+ flex-shrink: 0;
+}
+
+.pos .order-button .order-sequence{
+ font-size: 16px;
+ font-weight: 800;
+ vertical-align: middle;
+}
+.pos .order-button.selected .order-sequence{
+ color: white;
+ background: black;
+ display: inline-block;
+ line-height: 24px;
+ min-width: 24px;
+ border-radius: 12px;
+ margin-right: 4px;
+ margin-left: -4px;
+}
+
+.pos .order-button.square{
+ margin-left:1px;
+ background: #5c5c5c;
+ color: rgb(160,160,160);
+ font-size: 18px;
+ line-height: 45px;
+}
+.pos .order-button:not(.square) > .fa {
+ font-size: 16px;
+ vertical-align: middle;
+ margin-right: 4px;
+}
+.pos .order-button .order-sequence{
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.pos .order-selector {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+}
+.pos .orders {
+ display: -webkit-flex;
+ display: flex;
+ vertical-align: top;
+ margin-left: 0px;
+ overflow: hidden;
+}
+
+/* c) The session buttons */
+
+.pos .pos-rightheader .header-button{
+ float: right;
+ height: 48px;
+ padding-left: 16px;
+ padding-right: 16px;
+ border-right: 1px solid #875A7B;
+ border-left: 1px solid #875A7B;
+ color: #DDD;
+ line-height: 48px;
+ text-align: center;
+ cursor: pointer;
+
+ -webkit-transition-property: background;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: ease-out;
+}
+.pos .pos-rightheader .header-button:last-child{
+ border-left: 1px solid #875A7B;
+}
+.pos .pos-rightheader .header-button:active{
+ background: rgba(0,0,0,0.2);
+ color:#EEE;
+}
+.pos .pos-rightheader .header-button.confirm {
+ background: #359766;
+ color: white;
+ font-weight: bold;
+}
+
+/* c) The notifications indicator */
+
+.pos .oe_status{
+ float:right;
+ color: rgba(255,255,255,0.4);
+ padding: 14px;
+ line-height: 20px;
+ font-size: 20px;
+ vertical-align:middle;
+ font-style: italic;
+ cursor:pointer;
+}
+.pos .oe_status.oe_inactive{
+ cursor: default;
+}
+.pos .oe_status .oe_icon{
+ display:inline-block;
+ cursor:pointer;
+ width:20px; height:16px;
+ color: white;
+}
+.pos .oe_status .oe_red,
+.pos .oe_icon.oe_red {
+ color: rgb(197, 52, 0);
+}
+.pos .oe_status .oe_green,
+.pos .oe_icon.oe_green {
+ color: rgb(94, 185, 55);
+}
+.pos .oe_status .oe_orange,
+.pos .oe_icon.oe_orange {
+ color: rgb(239, 153, 65);
+}
+.pos .oe_link_icon{
+ cursor:pointer;
+}
+/* ********* Contains everything below the bar ********* */
+
+.pos .pos-content {
+ width: 100%;
+ position: absolute;
+ top: 48px;
+ bottom: 0;
+ background: #F0EEEE;
+}
+
+/* ********* The leftpane contains the order, numpad and paypad ********* */
+
+.pos .switchpane {
+ height: 100px;
+ flex-shrink: 0;
+ display: flex;
+}
+
+.pos .switchpane .order-info {
+ flex-grow: 1;
+ border-top: 1px solid #ebebeb;
+ padding: 8px 16px;
+ background-color: #FFFFFF;
+ color: #6ec89b;
+}
+.pos .switchpane .order-info h2 {
+ padding: 0px;
+ margin: 3px 0px;
+ font-weight: bold;
+}
+
+.pos .switchpane .btn-switchpane {
+ background-color: #6ec89b;
+ border-radius: 0px;
+ color: #FFFFFF;
+ font-size: 15px;
+ font-weight: bold;
+ flex-grow: 1;
+ flex-basis: 50%;
+ padding-bottom: 20px;
+}
+.pos .switchpane .btn-switchpane h1 {
+ margin-bottom: 0px;
+}
+
+.pos .switchpane .btn-switchpane.secondary {
+ background-color: #FFFFFF;
+ color: #6ec89b;
+}
+
+
+
+.pos .leftpane {
+ border-right: solid 3px #787878;
+ background: #e2e2e2;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ max-width: 500px;
+}
+
+.pos .leftpane .pads {
+ border-top: solid 3px rgb(110, 200, 155);
+}
+
+.pos .leftpane .pads .subpads {
+ display: flex;
+ flex-direction: row;
+}
+
+/* ********* The control buttons ********* */
+
+.pos .control-buttons {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-flow: row wrap;
+ flex-flow: row wrap;
+ padding: 8px 16px 0px 11px;
+ margin-bottom: -6px;
+}
+.pos .control-button {
+ -webkit-flex-grow: 1;
+ flex-grow: 1;
+ background: #e2e2e2;
+ border: solid 1px #bfbfbf;
+ display: inline-block;
+ line-height: 38px;
+ min-width: 80px;
+ text-align: center;
+ border-radius: 3px;
+ padding: 0px 10px;
+ font-size: 18px;
+ margin-left: 6px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ overflow: hidden;
+ transition: all linear 150ms;
+}
+.pos .control-button:hover {
+ background: #efefef;
+}
+.pos .control-button:active {
+ background: black;
+ color: white;
+ border-color: black;
+}
+.pos .control-button .fa{
+ margin-right: 4px;
+}
+.pos .control-button .control-button-number {
+ color: rgb(226, 226, 226);
+ background: rgb(85, 85, 85);
+ display: inline-block;
+ height: 28px;
+ vertical-align: middle;
+ font-weight: bold;
+ line-height: 28px;
+ width: 28px;
+ border-radius: 50%;
+ text-align: center;
+ margin-left: -16px;
+ margin-right: 4px;
+}
+
+.pos .control-button.highlight,
+.pos .button.highlight {
+ background: #6EC89B !important;
+ border: solid 1px #64AF8A !important;
+ color: white !important;
+}
+.pos .control-button.altlight,
+.pos .button.altlight {
+ background: #7F82AC !important;
+ border: solid 1px #756A99 !important;
+ color: white !important;
+}
+.pos .control-button.disabled,
+.pos .control-button.disabled:active{
+ background: #e2e2e2;
+ border: solid 1px #BEBEBE;
+ opacity: 0.5;
+ cursor: default;
+ color: inherit;
+}
+
+/* ********* The actionpad (payment, set customer) ********* */
+
+.pos .actionpad {
+ padding: 0;
+ margin: 16px;
+ margin-top: 8px;
+ margin-right: 0;
+ text-align: center;
+ vertical-align: top;
+ border: none;
+ border-radius: 0;
+ border-top: 1px solid;
+ border-left: 1px solid;
+ border-color: #bfbfbf;
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+ flex-grow: 1;
+}
+.pos .actionpad .button {
+ position: relative;
+ display: block;
+ height: 54px;
+ width: 100%;
+ font-weight: bold;
+ vertical-align: middle;
+ color: #555555;
+ font-size: 14px;
+ border-radius: 0;
+ border: none;
+ border-right: 1px solid;
+ border-bottom: 1px solid;
+ border-color: #bfbfbf;
+ transition: all 150ms linear;
+}
+.pos .actionpad .button:hover {
+ background: #efefef;
+}
+.pos .actionpad .button:active {
+ background: black;
+ border-color: black;
+ color: white;
+}
+.pos .actionpad .button:first-child {
+ border-top-left-radius: 4px;
+}
+.pos .actionpad .button:last-child {
+ border-bottom-left-radius: 4px;
+}
+.pos .actionpad .button.pay {
+ height: 162px;
+}
+.pos .actionpad .button.pay .pay-circle {
+ display: block;
+ font-size: 32px;
+ line-height: 54px;
+ padding-top: 6px;
+ background: rgb(86, 86, 86);
+ color: white;
+ width: 60px;
+ margin: auto;
+ border-radius: 30px;
+ margin-bottom: 10px;
+}
+.pos .actionpad .button.pay .pay-circle .fa {
+ position: relative;
+ top: -1px;
+ left: 3px;
+}
+
+.pos .actionpad .button.set-customer{
+ padding-left: 40px;
+ padding-right: 40px;
+}
+.pos .actionpad .button.set-customer.decentered {
+ padding-left: 40px;
+ padding-right: 5px;
+}
+.pos .actionpad .button .fa-user {
+ position: absolute;
+ left: 13px;
+ top: 13px;
+ margin-right: 8px;
+ font-size: 18px;
+ background: rgba(255, 255, 255, 0.5);
+ line-height: 30px;
+ width: 30px;
+ border-radius: 100%;
+}
+
+@media screen and (max-width: 768px) {
+ .pos .actionpad .button.set-customer{
+ padding-left: 0px;
+ padding-right: 0px;
+ }
+ .pos .actionpad .button.set-customer.decentered{
+ padding-left: 0px;
+ padding-right: 0px;
+ }
+}
+
+/* ********* The Numpad ********* */
+
+.pos .numpad {
+ text-align: center;
+ width: 216px;
+ margin: 16px;
+ margin-top: 8px;
+ margin-left: 0px;
+ border: none;
+ border-radius: 0;
+ border-top: 1px solid;
+ border-color: #bfbfbf;
+ border-top-right-radius: 4px;
+ min-width: 216px;
+}
+.pos .numpad button {
+ float: left/*rtl:ignore*/; /* rtlcss forced to keep ltr */
+ height: 54px;
+ width: 54px;
+ font-weight: bold;
+ vertical-align: middle;
+ color: #555555;
+ border-radius: 0;
+ border: none;
+ border-right: 1px solid;
+ border-bottom: 1px solid;
+ border-color: #bfbfbf;
+ transition: all 150ms linear;
+}
+.pos .numpad button:hover {
+ background: #efefef;
+}
+.pos .numpad button:active {
+ background: black;
+ color: white;
+ border-color: transparent;
+}
+.pos .numpad button:nth-child(4) {
+ border-top-right-radius: 4px;
+}
+.pos .numpad button:last-child {
+ border-bottom-right-radius: 4px;
+}
+.pos .input-button {
+ font-size: 24px;
+}
+.pos .mode-button {
+ font-size: 14px;
+}
+.pos .mode-button.selected-mode {
+ color: white;
+ background: #6EC89B;
+ border-color: transparent;
+}
+.pos .mode-button.selected-mode:hover {
+ background: #6EC89B;
+ color: white;
+ border-color: transparent;
+}
+.pos .numpad .disabled-mode, .pos .numpad .disabled-mode:hover {
+ background: #c7c7c7;
+ color: #a5a1a1;
+ cursor: not-allowed;
+}
+
+/* ********* The right pane contains the screens and headers ********* */
+
+.pos .rightpane {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ flex-basis: 25%;
+ flex-grow: 1;
+}
+
+.pos .products-widget {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.pos .product-list-container {
+ overflow: hidden;
+ overflow-y: auto;
+ flex-grow: 1;
+}
+
+.pos .rightpane-header {
+ padding: 0;
+ background: #d3d3d3;
+ text-align: center;
+ display: flex;
+ flex-flow: row wrap;
+}
+
+.pos .green-border-bottom {
+ border-bottom: solid 3px rgb(110, 200, 155);
+}
+
+.pos .grey-border-bottom {
+ border-bottom: 1px solid #c7c7c7;
+}
+
+/* ********* The product list ********* */
+
+.pos .product-list {
+ padding: 10px;
+ text-align: left;
+ -webkit-transform: translate3d(0,0,0);
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.pos .product-list-scroller{
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ width:100%;
+ height:100%;
+ overflow: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ -webkit-transform: translate3d(0,0,0);
+
+}
+
+/* a) the product list navigation bar */
+
+.pos .breadcrumb{
+ float: left;
+ display: inline-block;
+ line-height: 48px;
+ height: 48px;
+ min-width: 48px;
+}
+.pos .breadcrumb:last-child {
+ padding-right: 3px;
+ border-right: 1px solid #c5c5c5;
+}
+.pos .breadcrumb-button {
+ display: inline-block;
+ padding: 0 9px;
+ vertical-align: top;
+ color: #808080;
+ font-size: 14px;
+ cursor: pointer;
+}
+.pos .breadcrumb-button.breadcrumb-home {
+ line-height: 50px;
+ font-size: 25px;
+ text-align: center;
+}
+
+.pos .breadcrumb-arrow{
+ width: 28px;
+}
+.pos .breadcrumb-homeimg {
+ width: 27px;
+ margin: 12px 6px;
+}
+
+@media screen and (max-width: 768px) {
+ .pos .breadcrumb-button.breadcrumb-home {
+ width: auto;
+ font-size: 13px;
+ margin-left: 3px;
+ }
+}
+
+/* b) the search box */
+
+.pos .searchbox {
+ flex-grow: 1;
+ position: relative;
+}
+.pos .searchbox input {
+ width: 150px;
+ border: 1px solid #cecbcb;
+ padding: 10px 20px;
+ margin: 6px;
+ background-color: white;
+ border-radius: 20px;
+ font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial;
+ font-size: 13px;
+}
+.pos .searchbox input:focus {
+ outline: none;
+ box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset;
+ color: rgb(153, 153, 255);
+}
+
+.pos .search-clear {
+ top: 9px;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-clear.left {
+ left: 11px;
+ color: #808080;
+ margin: 6px;
+}
+
+.search-clear.right {
+ color: #808080;
+ cursor: pointer;
+ margin: 6px;
+}
+
+@media screen and (max-width: 768px) {
+ .search-clear.left {
+ position: relative;
+ top: -40px;
+ left: 5%;
+ }
+
+ .search-clear.right {
+ position: relative;
+ top: -70px;
+ left: 85%;
+ }
+ .pos .searchbox input {
+ width: 70%;
+ }
+ .pos .searchbox {
+ position: relative;
+ }
+}
+
+/* c) the categories list */
+
+.pos .categories {
+ position: relative;
+ border-bottom: solid 3px rgb(110, 200, 155);
+ flex: 1;
+}
+.pos .categories h4 {
+ display: inline-block;
+ margin: 9px 5px;
+}
+.pos .category-list-scroller{
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ width:100%;
+ height:100%;
+ max-height:40vh;
+ overflow: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ -webkit-transform: translate3d(0,0,0);
+
+}
+.pos .category-list {
+ text-align: left;
+ padding: 10px;
+ background: rgb(229, 229, 229);
+}
+.pos .category-list.simple {
+ padding: 0px;
+ background: #cecece;
+ display: flex;
+ flex-flow: row wrap;
+ flex: 1;
+}
+
+
+/* d) the category button */
+
+.pos .category-button {
+ position: relative;
+ vertical-align: top;
+ display: inline-block;
+ font-size: 11px;
+ margin: 8px !important;
+ width: 120px;
+ height:120px;
+ background:#fff;
+ border: 1px solid #d7d7d7;
+ border-radius: 3px;
+ border-bottom-width: 3px;
+ cursor: pointer;
+}
+
+.pos .category-simple-button{
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ padding: 5px 12px;
+ cursor: pointer;
+ flex: 1;
+ text-align: left;
+ background: rgb(229, 229, 229);
+ border-right: solid 1px #d3d3d3;
+ border-top: solid 1px #d3d3d3;
+}
+.pos .category-simple-button:active{
+ color: white;
+ background: black;
+
+ -webkit-transition-property: background, border;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: ease-out;
+}
+
+
+
+.pos .category-button .category-img {
+ position: relative;
+ width: 120px;
+ height: 100px;
+ text-align: center;
+ cursor: pointer;
+}
+
+.pos .category-button .category-img img {
+ max-height: 100px;
+ max-width: 120px;
+ vertical-align: middle;
+}
+
+.pos .category-button .category-name {
+ position: absolute;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ bottom: 0;
+ top: auto;
+ line-height: 14px;
+ width: 100%;
+ /* for some reason the -90deg orientation doesn't match the -webkit-linear-gradient. It should be 180deg here.
+ * webkit also insists on rendering *both* gradients instead of only the native one. So it doesn't looks right. ugh.
+ background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); */
+ /*background:#FFF;*/
+ padding: 3px;
+ padding-top: 15px;
+ color: #7C7BAD;
+}
+
+/* e) the product */
+
+.pos .product {
+ position:relative;
+ vertical-align: top;
+ display: inline-block;
+ line-height: 100px;
+ font-size: 11px;
+ margin: 8px !important;
+ width: 122px;
+ height:115px;
+ background:#fff;
+ border: 1px solid #e2e2e2;
+ border-radius: 3px;
+ border-bottom-width: 3px;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.pos .product .product-img {
+ position: relative;
+ width: 120px;
+ height: 100px;
+ background: white;
+ text-align: center;
+}
+
+.pos .product .product-img img {
+ max-height: 100px;
+ max-width: 120px;
+ vertical-align: middle;
+}
+
+@media screen and (max-width: 768px) {
+ .pos .product-list {
+ padding: 0px;
+ }
+ .pos .product {
+ width: 32%;
+ height: auto;
+ margin: 0px !important;
+ }
+ .pos .product:active {
+ border: solid 50px #6ec89b;
+ box-sizing: border-box;
+ }
+ .pos .product:after {
+ content: "";
+ display: block;
+ padding-bottom: 100%;
+ }
+ .pos .product .product-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ }
+ .pos .product .product-img img {
+ max-height: none;
+ max-width: none;
+ }
+
+}
+
+
+
+.pos .product .price-tag {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ vertical-align: top;
+ color: white;
+ line-height: 13px;
+ background: #7f82ac;
+ padding: 2px 5px;
+ border-radius: 2px;
+}
+
+.pos .product .product-name {
+ position: absolute;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ bottom:0;
+ top:auto;
+ line-height: 14px;
+ width:100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ background: -webkit-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ background: -moz-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ background: -ms-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ /* troublesome in latest webkit
+ background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1));
+ */
+ /*background:#FFF;*/
+ padding: 3px;
+ padding-top:15px;
+}
+
+
+/* ********* The Screens ********* */
+
+.pos .screen {
+ position:absolute;
+ text-align: center;
+ top:0px;
+ bottom:0px;
+ width:100%;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+}
+.pos .screen header h2 {
+ margin-top: 0px;
+ padding-top: 7px;
+}
+.pos .screen p{
+ font-size: 18px;
+}
+.pos .dialog{
+ width: 500px;
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: 50px;
+ text-align: center;
+}
+.pos .dialog p{
+ font-size: 25px;
+ margin-top: 10px;
+ color: #5a5a5a;
+}
+
+/* a) Generic Screen Layout Constructs */
+
+.screen .screen-content{
+ position: relative;
+ margin: 0px auto;
+ max-width: 1024px;
+ text-align: left;
+ height: 100%;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.screen .screen-full-width{
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+}
+
+@media screen and (min-width: 1024px) {
+ .screen .screen-content{
+ border-left: dashed 1px rgb(215,215,215);
+ border-right: dashed 1px rgb(215,215,215);
+ }
+}
+
+.screen .top-content{
+ height: 64px;
+ border-bottom: dashed 1px rgb(215,215,215);
+ text-align: center;
+ display: flex;
+ padding-right: 10px;
+ padding-left: 10px;
+}
+.screen .top-content .button {
+ line-height: 32px;
+ padding: 3px 13px;
+ font-size: 20px;
+ background: rgb(230, 230, 230);
+ margin-top: 12px;
+ margin-bottom: 12px;
+ margin-left: 6px;
+ margin-right: 6px;
+ border-radius: 3px;
+ border: solid 1px rgb(209, 209, 209);
+ cursor: pointer;
+ transition: all 150ms linear;
+}
+.screen .top-content .button:hover {
+ background: #efefef;
+}
+.screen .top-content .button:active {
+ background: black;
+ border-color: black;
+ color: white;
+}
+.screen .top-content .button.highlight {
+ background: rgb(110,200,155);
+ color: white;
+ border: solid 1px rgb(110,200,155);
+}
+.screen .top-content .button.highlight:hover {
+ background: rgb(120,210,165);
+}
+
+.screen .top-content .top-content-center {
+ flex-grow: 1;
+}
+
+.screen .main-content{
+ display: flex;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ overflow-y: auto;
+}
+.screen .left-content{
+ overflow-x: hidden;
+ overflow-y: auto;
+ border-right: dashed 1px rgb(215,215,215);
+ flex-grow: 1;
+ min-width: 200px;
+}
+
+.screen .right-content{
+ overflow-x: hidden;
+ overflow-y: auto;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+@media screen and (min-width: 768px) {
+ .screen .left-content {
+ max-width: 34%
+ }
+}
+
+.pos .btn-switch-payment {
+ background-color: #6ec89b;
+ border-radius: 0px;
+ color: #FFFFFF;
+ font-size: 15px;
+ font-weight: bold;
+ height: 100px;
+ width: 100%;
+}
+.screen .centered-content{
+ border-right: dashed 1px rgb(215,215,215);
+ border-left: dashed 1px rgb(215,215,215);
+ overflow-x: hidden;
+ overflow-y: auto;
+ max-width: 512px;
+ margin-left: auto;
+ margin-right: auto;
+}
+.screen .full-content{
+ position: absolute;
+ right: 0%; top: 65px; bottom: 0px;
+ left: 0%;
+}
+
+/* a) Layout for the Product Screen */
+
+.pos .screen .layout-table {
+ border:none;
+ width:100%;
+ display: flex;
+ flex-grow: 1;
+}
+
+.pos .screen .header-cell{
+ border:none;
+ width:100%;
+ height:0px;
+}
+.pos .screen .content-row {
+ width:100%;
+ height:100%;
+}
+.pos .screen .content-cell{
+ width:100%;
+}
+.pos .screen .content-cell .content-container{
+ height:100%;
+ position:relative;
+}
+
+
+/* b) The payment screen */
+
+.pos .payment-buttons-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+}
+
+.pos .payment-numpad {
+ box-sizing: border-box;
+ margin: 16px;
+ text-align: center;
+ flex-grow: 1;
+}
+.pos .payment-numpad .numpad {
+ border-radius: 4px;
+ border-top: 1px solid;
+ border-left: 1px solid;
+ border-color: #cacaca;
+ width: 296px;
+ height: 100%;
+ margin: auto;
+}
+.pos .payment-numpad .numpad button {
+ width: 74px;
+ height: 74px;
+}
+.pos .payment-numpad .numpad button:first-child {
+ border-top-left-radius: 4px;
+}
+.pos .payment-numpad .numpad button:nth-child(16) {
+ border-bottom-left-radius: 4px;
+}
+
+.pos .paymentlines-container {
+ padding: 16px;
+ padding-top: 0;
+ border-bottom: dashed 1px gainsboro;
+ min-height: 154px;
+}
+
+.pos .paymentlines {
+ border-spacing: 0px 10px;
+ border-collapse: inherit;
+ margin: 16px;
+}
+
+.paymentlines .paymentline:first-child {
+ border-top-width: 1px;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+.paymentlines .paymentline:last-child {
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+
+.pos .paymentline {
+ background: #e2e2e2;
+ line-height: 74px;
+ font-size: 16px;
+ border: solid 1px rgb(202, 202, 202);
+ border-top-width: 0px;
+ cursor: pointer;
+ display: flex;
+ padding-left: 30px;
+ padding-right: 30px;
+ flex-grow: 1;
+ flex-basis: 100%;
+}
+.paymentline:active {
+ background: black;
+ border-color: black;
+ color: white;
+}
+.paymentline .payment-name {
+ flex-grow: 1;
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.paymentline .payment-amount {
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.paymentline .delete-button {
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.paymentline.selected{
+ background: white;
+}
+
+.pos .payment-buttons {
+ box-sizing: border-box;
+ margin: 16px;
+ padding-left: 0;
+ flex-grow: 5;
+}
+.payment-screen .customer-button {
+ margin-bottom: 10px;
+}
+.payment-screen .payment-buttons .button {
+ background: #e2e2e2;
+ line-height: 73px;
+ font-size: 16px;
+ padding: 0px 8px;
+ border: solid 1px rgb(200,200,200);
+ border-top-width: 0;
+ cursor: pointer;
+ text-align: center;
+ position: relative;
+ transition: background-color, border-color, color 150ms linear;
+}
+.payment-screen .payment-buttons .button:hover {
+ background-color: #efefef;
+}
+.payment-screen .payment-buttons .button:first-child {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ border-top-width: 1px;
+}
+.payment-screen .payment-buttons .button:last-child {
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+.payment-screen .payment-buttons .button.highlight:not(:first-child) {
+ margin-top: -1px;
+ border-top: solid 1px;
+}
+.payment-screen .payment-buttons .button:active {
+ background: black;
+ border-color: black;
+ color: white;
+}
+.payment-screen .payment-buttons .button.highlight .fa {
+ border-color: rgba(0, 0, 0, 0.109804);
+ background: rgba(0, 0, 0, 0.0980392);
+}
+.payment-screen .payment-buttons .button .fa {
+ position: absolute;
+ left: 11px;
+ top: 50%;
+ width: 48px;
+ height: 48px;
+ line-height: 48px;
+ margin-top: -25px;
+ vertical-align: middle;
+ border-radius: 26px;
+ border: 1px solid rgba(0,0,0,0.2);
+ border-image-source: initial;
+ border-image-slice: initial;
+ border-image-width: initial;
+ border-image-outset: initial;
+ border-image-repeat: initial;
+ background: rgba(255,255,255,0.4);
+ font-size: 20px;
+ transition: all 150ms linear;
+}
+.payment-screen .paymentlines-empty .total {
+ text-align: center;
+ padding: 24px 0px 18px;
+ font-size: 64px;
+ color: #43996E;
+ text-shadow: 0px 2px white, 0px 2px 2px rgba(0, 0, 0, 0.27);
+}
+.payment-screen .paymentlines-empty .message {
+ text-align: center;
+}
+
+.paymentlines .button {
+ cursor: pointer;
+ border: 1px solid #cacaca;
+}
+.paymentlines .electronic_payment {
+ background: #e2e2e2;
+ border-collapse: unset;
+ font-size: 16px;
+ padding-right: 0;
+}
+
+.paymentlines .electronic_payment div:first-child {
+ flex-grow: 2;
+ margin-left: 10px;
+ margin-right: 10px;
+}
+
+.paymentlines .electronic_payment div:last-child {
+ flex-grow: 1;
+ text-align: center;
+}
+
+.payment-status-container {
+ display: flex;
+ justify-content: space-between;
+ font-size: 25px;
+ padding-top: 15px;
+}
+
+.payment-status-total-due {
+ font-size: 17px;
+ padding-top: 12px;
+ padding-bottom: 12px;
+ color: #5c5c5cd1;
+}
+
+.payment-status-container .amount.highlight {
+ font-weight: bold;
+ color: #6EC89B;
+}
+
+.payment-status-container .label {
+ padding-right: 7px;
+}
+
+@media screen and (max-width: 768px) {
+ .pos .paymentlines-container {
+ min-height: 0px;
+ border-bottom: none;
+ padding-bottom: 3px;
+ }
+ .pos .paymentlines {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+ .payment-status-container {
+ font-size: 22px;
+ }
+ .payment-screen .payment-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: column;
+ }
+ .payment-screen .payment-controls {
+ display: flex;
+ flex-wrap: wrap;
+ }
+ .payment-screen .payment-buttons .button {
+ flex-basis: 30%;
+ flex-grow: 1;
+ border: 1px solid rgba(0,0,0,0.2);
+ border-radius: 0px;
+ line-height: normal;
+ padding-top: 28px;
+ padding-bottom: 28px;
+ }
+ .payment-screen .payment-buttons .button .fa {
+ display: none;
+ }
+}
+
+/* c) The receipt screen */
+
+.pos .receipt-screen .centered-content .button {
+ line-height: 40px;
+ padding: 3px 13px;
+ font-size: 20px;
+ text-align: center;
+ background: rgb(230, 230, 230);
+ margin: 16px;
+ margin-bottom: 0px;
+ border-radius: 3px;
+ border: solid 1px rgb(209, 209, 209);
+ cursor: pointer;
+}
+
+.pos .pos-receipt-container {
+ font-size: 0.75em;
+ text-align: center;
+ direction: ltr;
+}
+
+.pos .pos-receipt-container > div {
+ text-align: left;
+ width: 300px;
+ background-color: white;
+ margin: 20px;
+ padding: 15px;
+ font-size: 16px;
+ padding-bottom:30px;
+ display: inline-block;
+ border: solid 1px rgb(220,220,220);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+@page {
+ margin: 0;
+}
+
+@media print {
+ body {
+ background: white;
+ }
+ body * {
+ visibility: hidden;
+ }
+ .pos, .pos * {
+ position: static !important;
+ }
+ .pos .receipt-screen .pos-receipt-container {
+ position: absolute !important;
+ top: 0;
+ left: 0;
+ }
+ .pos .receipt-screen .pos-receipt-container, .pos .receipt-screen .pos-receipt-container * {
+ visibility: visible;
+ background: white !important;
+ color: black !important;
+ }
+ .pos .pos-receipt {
+ margin: 0 !important;
+ margin-left: auto !important;
+ margin-right: auto !important;
+ border: none !important;
+ font-size: 14px !important;
+ width: 266px !important;
+ }
+}
+
+/* d) The Scale screen */
+
+.pos .scale-screen .product-price{
+ font-size: 25px;
+ margin: 16px;
+ text-align: center;
+ display: inline-block;
+ width: 35%;
+}
+.pos .scale-screen .computed-price{
+ font-size: 25px;
+ display: inline-block;
+ text-align: right;
+ margin: 16px;
+ margin-top: 0px;
+ padding: 16px;
+ background: white;
+ width: 35%;
+ border-radius: 3px;
+ font-family: Inconsolata;
+ font-weight: bold;
+ text-shadow: 0px 2px 0px rgb(210,210,210);
+ box-shadow: 0px 2px 0px rgb(225,225,225) inset;
+ float: right;
+}
+.pos .scale-screen .buy-product{
+ text-align: center;
+ font-size: 32px;
+ background: rgb(110,200,155);
+ color: white;
+ border-radius: 3px;
+ padding: 16px;
+ margin: 16px;
+ cursor: pointer;
+}
+
+.pos .scale-screen .weight{
+ text-align: right;
+ margin: 16px;
+ background: white;
+ padding: 20px;
+ padding-right: 30px;
+ font-size: 56px;
+ border-radius: 3px;
+ font-family: Inconsolata;
+ text-shadow: 0px 2px 0px rgb(210, 210, 210);
+ box-shadow: 0px 2px 0px rgb(225,225,225) inset;
+}
+
+
+/* e) The Client List Screen */
+
+.pos .clientlist-screen .full-content{
+ overflow: auto;
+}
+
+.pos .clientlist-screen .client-list{
+ font-size: 16px;
+ width: 100%;
+}
+.pos .clientlist-screen .client-list th,
+.pos .clientlist-screen .client-list td {
+ padding: 12px 8px;
+}
+.pos .clientlist-screen .client-list tr{
+ transition: all 150ms linear;
+ background: rgb(230,230,230);
+}
+.pos .clientlist-screen .client-list thead > tr,
+.pos .clientlist-screen .client-list tr:nth-child(even) {
+ background: rgb(247,247,247);
+}
+.pos .clientlist-screen .client-list tr.highlight{
+ transition: all 150ms linear;
+ background: rgb(110,200,155) !important;
+ color: white;
+}
+.pos .clientlist-screen .client-list tr.lowlight{
+ transition: all 150ms linear;
+ background: rgb(216, 238, 227);
+}
+.pos .clientlist-screen .client-list tr.lowlight:nth-child(even){
+ transition: all 150ms linear;
+ background: rgb(227, 246, 237);
+}
+.pos .client-line {
+ vertical-align: text-top;
+}
+.pos .edit-client-button {
+ margin-top: 6px;
+ color: black;
+}
+.pos .clientlist-screen .client-details{
+ padding: 16px;
+ border-bottom: solid 5px rgb(110,200,155);
+}
+.pos .clientlist-screen .client-picture{
+ height: 64px;
+ width: 64px;
+ border-radius: 32px;
+ overflow: hidden;
+ text-align: center;
+ float: left;
+ margin-right: 16px;
+ background: white;
+ position: relative;
+}
+.pos .clientlist-screen .client-picture > img {
+ position: absolute;
+ top: -9999px;
+ bottom: -9999px;
+ right: -9999px;
+ left: -9999px;
+ max-height: 64px;
+ margin: auto;
+}
+.pos .clientlist-screen .client-picture > .fa {
+ line-height: 64px;
+ font-size: 32px;
+}
+.pos .clientlist-screen .client-picture .image-uploader {
+ position: absolute;
+ z-index: 1000;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+.pos .clientlist-screen .client-name {
+ font-size: 32px;
+ line-height: 64px;
+ margin-bottom:16px;
+}
+.pos .clientlist-screen .edit-buttons {
+ position: absolute;
+ right: 16px;
+ top: 10px;
+}
+.pos .clientlist-screen .edit-buttons .button{
+ display: inline-block;
+ margin-left: 16px;
+ color: rgb(128,128,128);
+ cursor: pointer;
+ font-size: 36px;
+}
+.pos .clientlist-screen .client-details-box{
+ position: relative;
+ font-size: 16px;
+}
+.pos .clientlist-screen .client-details-left{
+ width: 50%;
+ float: left;
+}
+.pos .clientlist-screen .client-details-right{
+ width: 50%;
+ float: right;
+}
+.pos .clientlist-screen .client-detail{
+ line-height: 24px;
+}
+.pos .clientlist-screen .client-detail > .label{
+ font-weight: bold;
+ display: inline-block;
+ width: 75px;
+ text-align: right;
+ margin-right: 8px;
+}
+.pos .clientlist-screen .client-details input,
+.pos .clientlist-screen .client-details select
+{
+ padding: 4px;
+ border-radius: 3px;
+ border: solid 1px #cecbcb;
+ margin-bottom: 4px;
+ background: white;
+ font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial;
+ color: #555555;
+ width: 340px;
+ font-size: 14px;
+ box-sizing: border-box;
+}
+.pos .clientlist-screen .client-details input.client-name {
+ font-size: 24px;
+ line-height: 24px;
+ margin: 18px 6px;
+ width: 340px;
+}
+.pos .clientlist-screen .client-detail > .empty{
+ opacity: 0.3;
+}
+
+.pos .clientlist-screen .button.new-customer {
+ min-width: 30px;
+}
+
+.pos .searchbox-client {
+ padding: 3px 13px;
+ margin-top: 12px;
+ margin-bottom : 12px;
+}
+
+.pos .searchbox-client input {
+ width: 120px;
+ border: 1px solid #cecbcb;
+ padding: 10px 20px;
+ padding-left: 38px;
+ padding-right: 33px;
+ background-color: white;
+ border-radius: 20px;
+ font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial;
+ font-size: 13px;
+}
+.pos .searchbox-client input:focus {
+ outline: none;
+ box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset;
+ color: rgb(153, 153, 255);
+}
+
+.pos .search-clear-client {
+ position: absolute;
+ top: 9px;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.search-clear-client.left {
+ left: 11px;
+ color: #808080;
+}
+
+.search-clear-client.right {
+ left: 145px;
+ color: #808080;
+ cursor: pointer;
+}
+
+@media screen and (max-width: 768px) {
+ .searchbox-client.top-content-center {
+ display: flex
+ }
+ .pos .searchbox-client input {
+ width: auto;
+ flex-grow: 1;
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+ .pos .clientlist-screen .client-details-box {
+ display: flex;
+ flex-wrap: wrap;
+ }
+ .pos .clientlist-screen .client-details-left{
+ width: auto;
+ float: none;
+ flex-grow: 1;
+ }
+ .pos .clientlist-screen .client-details-right{
+ width: auto;
+ float: none;
+ flex-grow: 1;
+ }
+ .pos .clientlist-screen .client-detail{
+ display: flex;
+ flex-direction: column;
+ }
+ .pos .clientlist-screen .client-details input,
+ .pos .clientlist-screen .client-details select
+ {
+ width: 100%;
+ }
+ .pos .clientlist-screen .client-details input.client-name {
+ width: 100%;
+ }
+ .pos .clientlist-screen .client-detail > .label{
+ width: auto;
+ text-align: left;
+ }
+ .pos .clientlist-screen .client-list td {
+ overflow: hidden;
+ white-space: nowrap;
+ }
+}
+
+
+
+
+
+/* ********* The OrderWidget ********* */
+
+.pos .order-container{
+ top: 0px;
+ width:100%;
+ height: 100%;
+ background: white;
+ flex-grow: 1;
+ overflow: hidden;
+ overflow-y: auto;
+}
+
+.pos .scrollable-y{
+ overflow: hidden !important;
+ overflow-y: auto !important;
+ -webkit-overflow-scrolling: touch !important;
+}
+
+.pos .order{
+ background: white;
+ padding-bottom: 8px;
+ padding-top: 8px;
+ font-size: 16px;
+ text-align: left;
+ max-width: 500px;
+ -webkit-transform: translate3d(0,0,0);
+}
+
+.pos .order .order-empty {
+ text-align: center;
+ margin: 48px;
+ color: #DDD;
+}
+.pos .order .order-empty .fa {
+ font-size: 64px;
+}
+.pos .order .order-empty h1 {
+ font-size: 20px;
+}
+
+.pos .order .summary{
+ width:100%;
+ text-align:right;
+ font-weight: bold;
+ margin-top:20px;
+ margin-bottom:10px;
+}
+.pos .order .summary .line{
+ float: right;
+ margin-right:15px;
+ margin-left: 15px;
+ padding-top:5px;
+ border-top: solid 2px;
+ border-color:#777;
+}
+.pos .order .summary .total {
+ font-size: 22px;
+}
+.pos .order .summary .line .subentry{
+ font-size: 16px;
+ font-weight: normal;
+ text-align: center;
+}
+.pos .order .summary .line.empty{
+ text-align: right;
+ border-color:#BBB;
+ color:#999;
+}
+
+.pos .order .summary .fidpoints{
+ position: absolute;
+ left: 20px;
+ padding: 10px;
+ color: #6EC89B;
+ background: rgba(110, 200, 155, 0.17);
+ border-radius: 3px;
+}
+
+.submit-kitchen-button {
+ float: left;
+ background: rgb(61, 235, 82);
+ color: white;
+ padding: 12px 20px;
+ margin: 0px 15px;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+/* ********* The OrderLineWidget ********* */
+
+.pos .order .orderline{
+ width:100%;
+ margin:0px;
+ padding-top:3px;
+ padding-bottom:10px;
+ padding-left:15px;
+ padding-right:15px;
+ cursor: pointer;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -ms-box-sizing: border-box;
+ box-sizing: border-box;
+ -webkit-transition: background 250ms ease-in-out;
+ -moz-transition: background 250ms ease-in-out;
+ transition: background 250ms ease-in-out;
+}
+.pos .order .orderline:active{
+ background: rgba(140,143,183,0.05);
+ -webkit-transition: background 50ms ease-in-out;
+ -moz-transition: background 50ms ease-in-out;
+ transition: background 50ms ease-in-out;
+}
+.pos .order .orderline.empty:active{
+ background: transparent;
+ cursor: default;
+}
+
+.pos .order .orderline.selected{
+ background: rgba(140,143,183,0.2);
+ -webkit-transition: background 250ms ease-in-out;
+ -moz-transition: background 250ms ease-in-out;
+ transition: background 250ms ease-in-out;
+ cursor: default;
+}
+.pos .order .orderline .product-name{
+ padding:0;
+ display:inline-block;
+ font-weight: bold;
+ width:80%;
+ overflow:hidden;
+ text-overflow: ellipsis;
+}
+.pos .order .orderline .price{
+ padding:0;
+ font-weight: bold;
+ float:right;
+}
+.pos .order .orderline .info-list{
+ color: #888;
+ margin-left:10px;
+}
+.pos .order .orderline .info-list em{
+ color: #777;
+ font-weight: bold;
+ font-style:normal;
+}
+
+/* ********* SplitBill ********* */
+
+.splitbill-screen .order-info {
+ text-align: center;
+ padding: 20px 0px;
+ font-size: 64px;
+ color: #43996E;
+ text-shadow: 0px 2px white, 0px 2px 2px rgba(0, 0, 0, 0.27);
+ border-bottom: dashed 1px rgb(215,215,215);
+}
+.pos .splitbill-screen .order {
+ background: white;
+ padding-bottom:15px;
+ padding-top:15px;
+ margin-left:16px;
+ margin-right:16px;
+ margin-top:16px;
+ margin-bottom:16px;
+ font-size:16px;
+ border-radius: 3px;
+ border: solid 1px rgb(220,220,220);
+ text-align: left;
+ max-width: 500px;
+ -webkit-transform: translate3d(0,0,0);
+ height: max-content;
+ width: 100%;
+}
+.splitbill-screen .order .orderline.selected{
+ background: rgb(110,200,155);
+ color: white;
+ -webkit-transition: background 250ms ease-in-out;
+ -moz-transition: background 250ms ease-in-out;
+ transition: background 250ms ease-in-out;
+ cursor: default;
+}
+.splitbill-screen .order .orderline.partially.selected{
+ background: rgb(136, 214, 176);
+}
+.splitbill-screen .order .orderline.selected .info-list {
+ color: white;
+}
+.splitbill-screen .order .orderline.selected .info-list em{
+ color: white;
+ font-size: 24px;
+ vertical-align: top;
+}
+.paymentmethods {
+ margin: 16px;
+ display: flex;
+ flex-wrap: wrap;
+}
+.paymentmethods .button {
+ background: #e2e2e2;
+ line-height: 74px;
+ font-size: 16px;
+ border: solid 1px rgb(202, 202, 202);
+ border-top-width: 0px;
+ cursor: pointer;
+}
+.paymentmethods .button:first-child {
+ border-top-width: 1px;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+.paymentmethods .button:last-child {
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+.paymentmethods .button:active {
+ background: black;
+ border-color: black;
+ color: white;
+}
+.paymentmethod .button.active {
+ background: #6EC89B;
+ color: white;
+ border-color: #6EC89B;
+}
+.paymentmethod {
+ display: flex;
+ padding-left: 30px;
+ padding-right: 30px;
+ flex-grow: 1;
+ flex-basis: 100%;
+}
+.paymentmethod .payment-name {
+ flex-grow: 1;
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.paymentmethod .payment-amount {
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.paymentmethod .delete-button {
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.paymentmethod.selected{
+ background: white;
+}
+
+@media screen and (max-width: 768px) {
+ .paymentmethods {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+ .paymentmethods .button {
+ border: solid 1px rgb(202, 202, 202);
+ border-radius: 3px;
+ }
+ .paymentmethod {
+ flex-basis: 30%;
+ }
+}
+
+/* ********* The ActionBarWidget ********* */
+
+.pos .pos-actionbar{
+ height: 105px;
+ background: #f5f5f5; /*#ebebeb;*/
+ border-top: solid 1px #cecece;
+ z-index:900;
+}
+
+.pos .pos-actionbar ul{
+ list-style: none;
+}
+
+.pos .pos-actionbar-button-list{
+ height: 100%;
+ margin: 0px;
+ padding-left:3px;
+ padding-right:3px;
+ overflow:hidden;
+}
+
+.pos .pos-actionbar .button{
+ width: 90px;
+ height: 90px;
+ text-align:center;
+ margin:3px;
+ margin-top:6px;
+ float:left;
+
+ font-size: 14px;
+ font-weight: bold;
+
+ cursor: pointer;
+
+ border: 1px solid #cacaca;
+ border-radius: 3px;
+
+ background: #e2e2e2;
+}
+.pos .pos-actionbar .button .label{
+ margin-top: 37px;
+}
+.pos .pos-actionbar .button .icon{
+ margin-top: 10px;
+}
+.pos .pos-actionbar .button:active{
+ color: white;
+ background: #7f82ac;
+ border: 1px solid #7f82ac;
+
+ -webkit-transition-property: background, border;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: ease-out;
+}
+
+.pos .pos-actionbar .button.disabled{
+ opacity: 0.5;
+}
+.pos .pos-actionbar .button.disabled:active{
+ border: 1px solid #cacaca;
+ color: #555;
+ cursor: default;
+
+ background: #e2e2e2;
+}
+
+.pos .pos-actionbar .button.rightalign{
+ float:right;
+}
+/* ********* The Debug Widget ********* */
+
+.pos .debug-widget{
+ z-index:100000;
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ width: 200px;
+ font-size: 10px;
+
+ background: rgba(0,0,0,0.82);
+ color: white;
+ padding-bottom: 10px;
+ cursor: move;
+ -webkit-transform: translate3d(0,0,0);
+}
+.pos .debug-widget .toggle{
+ position: absolute;
+ font-size: 16px;
+ cursor:pointer;
+ top:0px;
+ right:0px;
+ padding:10px;
+ padding-right:15px;
+}
+.pos .debug-widget .content{
+ overflow: hidden;
+}
+.pos .debug-widget header {
+ position: relative;
+}
+.pos .debug-widget h1{
+ background:black;
+ padding-top: 10px;
+ padding-left: 10px;
+ margin-top:0;
+ margin-bottom:0;
+}
+.pos .debug-widget .category{
+ background: black;
+ padding-left: 10px;
+ margin: 0px;
+ font-weight: bold;
+ padding-top:3px;
+ padding-bottom:3px;
+}
+.pos .debug-widget .button{
+ padding: 5px;
+ padding-left: 15px;
+ display: block;
+ cursor:pointer;
+}
+.pos .debug-widget .button:active{
+ background: rgba(96,21,177,0.45);
+}
+.pos .debug-widget input{
+ margin-left:10px;
+ margin-top:7px;
+ padding: 4px;
+ width: 180px;
+ border: none;
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ border-radius: 3px;
+}
+.pos .debug-widget .status{
+ padding: 5px;
+ padding-left: 15px;
+ display: block;
+ cursor:default;
+}
+.pos .debug-widget .status.on{
+ background-color: #6cd11d;
+}
+.pos .debug-widget .event{
+ padding: 5px;
+ padding-left: 15px;
+ display: block;
+ cursor:default;
+ background-color: #1E1E1E;
+}
+
+/* ********* The PopupWidgets ********* */
+
+.pos .modal-dialog{
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height:100%;
+ background-color: rgba(0,0,0,0.5);
+ z-index:1000;
+}
+.pos .modal-dialog header{
+ position: relative;
+}
+.pos .modal-dialog .popup{
+ position: absolute;
+ margin: auto;
+ max-width:500px;
+ width: 100%;
+ text-align:center;
+ font-size:20px;
+ font-weight:bold;
+ background-color: #F0EEEE;
+ border-radius: 3px;
+ box-shadow: 0px 10px 20px rgba(0,0,0,0.4);
+ 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-lg{
+ max-width: 80%;
+ max-height: 600px;
+ height: auto;
+}
+
+.pos .popup .title {
+ background: rgba(255,255,255,0.5);
+ margin: 0;
+ padding: 20px;
+ border-radius: 3px 3px 0px 0px;
+ border-bottom: solid 1px rgba(60,60,60,0.1);
+}
+.pos .popup .body {
+ font-weight: normal;
+ font-size: 18px;
+ margin: 16px;
+}
+
+.pos .popup-lg .body {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.pos .popup .body.traceback {
+ height: 238px;
+ overflow: auto;
+ font-size: 14px;
+ white-space: pre-wrap;
+ text-align: left;
+ font-family: 'Inconsolata';
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ user-select: text;
+}
+.pos .popup .footer{
+ width:100%;
+ height:60px;
+ border-top: solid 1px rgba(60,60,60,0.1);
+}
+.pos .popup .button{
+ float:right;
+ width: 110px;
+ height: 40px;
+ line-height:40px;
+ text-align:center;
+ border-radius: 2px;
+ margin-top:10px;
+ margin-right:10px;
+
+ font-size: 14px;
+ font-weight: bold;
+
+ cursor: pointer;
+
+ border: solid 1px rgba(60,60,60,0.1);
+
+ background: rgba(0,0,0,0.05);
+}
+
+.pos .popup .button.dont-show-again {
+ width: 130px;
+}
+
+.pos .popup .button.icon {
+ width: 40px;
+ font-size: 20px;
+}
+.pos .popup .button:active{
+ color: white;
+ background: black;
+ border: 1px solid black;
+
+ -webkit-transition-property: background, border;
+ -webkit-transition-duration: 0.2s;
+ -webkit-transition-timing-function: ease-out;
+}
+
+
+.pos .popup .button.big-left{
+ position:absolute;
+ top: 120px;
+ left:40px;
+ width: 180px;
+ height: 180px;
+ line-height:180px;
+}
+
+.pos .popup .button.big-right{
+ position:absolute;
+ top: 120px;
+ right:40px;
+ width: 180px;
+ height: 180px;
+ line-height:180px;
+}
+.pos .popup input,
+.pos .popup-input {
+ text-align: left;
+ display: inline-block;
+ overflow: hidden;
+ background: white;
+ min-height: 44px;
+ font-family: "Lato";
+ font-size: 20px;
+ color: #444;
+ padding: 10px;
+ border-radius: 3px;
+ border: none;
+ box-shadow: 0px 0px 0px 1px rgb(220,220,220) inset;
+ box-sizing: border-box;
+ width: 80%;
+}
+.pos .popup .list-lines{
+ overflow: auto;
+ height: 250px;
+ margin: 10px;
+}
+.pos .popup .list-line-input {
+ margin: 3px;
+}
+
+.pos .popup-number .popup-input {
+ text-align: center;
+}
+.pos .popup input:focus,
+.pos .popup-input.active {
+ outline: none;
+ box-shadow: 0px 0px 0px 3px #6EC89B;
+}
+.pos .popup.popup-error {
+ background-color: #F3BBBB;
+ color: rgb(168, 89, 89);
+ box-shadow: 0px 10px 20px rgba(92,51,51,0.4);
+}
+.pos .popup.popup-error .title {
+ color: white;
+ background: rgba(255, 76, 76, 0.5);
+}
+.pos .popup.popup-selection .selection {
+ overflow-y: auto;
+ max-height: 273px;
+ font-size: 16px;
+ width: auto;
+ line-height: 50px;
+ margin-top: -1px;
+ border-top: solid 3px rgba(60,60,60,0.1);
+
+}
+.pos .popup.popup-selection .selection-item {
+ width: auto;
+ background: rgb(230,230,230);
+ cursor: pointer;
+ text-align: left;
+ padding: 0px 16px;
+}
+.pos .popup.popup-selection .selection-item:nth-child(odd) {
+ background: rgb(247,247,247);
+}
+.pos .popup.popup-selection .selection-item.selected {
+ background: #6EC89B;
+}
+.pos .popup.popup-number {
+ width: 300px;
+ height: 450px;
+}
+.pos .footer.centered {
+ text-align: center;
+}
+.pos .footer.centered .button {
+ float: none;
+ display: inline-block;
+ margin-left: 3px;
+ margin-right: 3px;
+}
+.pos .popup-numpad {
+ direction: ltr/*rtl:ignore*/; /* rtlcss forced to keep ltr */
+ margin: 12px auto;
+ text-align: center;
+ width: 254px;
+}
+.pos .popup-number .title,
+.pos .popup-textinput .title
+{
+ margin-bottom: 20px;
+}
+.pos .popup-numpad .input-button,
+.pos .popup-numpad .mode-button {
+ background: none;
+ height: 50px;
+ width: 50px;
+ padding: 0;
+ border-radius: 25px;
+ margin: 4px;
+ vertical-align: top;
+ color: #444;
+}
+.pos .popup-numpad .input-button:active,
+.pos .popup-numpad .mode-button:active {
+ background: #444;
+ color: white;
+ border-color: #444;
+}
+
+.pos .popup.popup-password {
+ width: 254px;
+}
+.pos .popup-password .mode-button.add,
+.pos .popup-password .input-button.dot {
+ display: none;
+}
+.pos .popup-password .popup-numpad {
+ width: 190px;
+}
+.pos .popup-password .popup-input {
+ width: 70%;
+ }
+
+.pos .popup .body ul,
+.pos .popup ul.body {
+ text-align: left;
+ margin-left: 1em;
+}
+.pos .popup .body li {
+ text-indent: 1em;
+}
+.pos .popup .body li:before {
+ content: '—';
+ position: relative;
+ font-size: 0.6em;
+ left: -1em;
+ bottom: 0.2em;
+}
+
+
+/* ********* The Webkit Scrollbar ********* */
+
+.pos *::-webkit-scrollbar{
+ width: 4px;
+ height: 4px;
+}
+.pos *::-webkit-scrollbar-track{
+ background: rgb(224,224,224);
+ border-left: solid 1px rgb(200,200,200);
+}
+.pos *::-webkit-scrollbar-thumb{
+ background: rgb(168,168,168);
+ background: #393939;
+ min-height: 30px;
+}
+
+.pos.big-scrollbars *::-webkit-scrollbar{
+ width: 40px;
+ height: 40px;
+}
+.pos.big-scrollbars *::-webkit-scrollbar-track{
+ background: rgb(224,224,224);
+ border-left: none;
+}
+.pos.big-scrollbars *::-webkit-scrollbar-thumb{
+ background: rgb(168,168,168);
+ min-height: 40px;
+ border-radius: 3px;
+}
+.pos.big-scrollbars *::-webkit-scrollbar-button{
+ width: 40px;
+ height: 40px;
+ border-radius: 3px;
+ background: rgb(210,210,210);
+ background-size: cover;
+}
+.pos.big-scrollbars *::-webkit-scrollbar-button:decrement{
+ background-image: url('../img/scroll-up.png');
+}
+.pos.big-scrollbars *::-webkit-scrollbar-button:increment{
+ background-image: url('../img/scroll-down.png');
+}
+
+
+/* ********* Unsupported Browser Page ********* */
+
+.pos .not-supported-browser{
+ position: absolute;
+ z-index: 100000;
+ top: 0; bottom: 0; left: 0; right: 0;
+ background: #2C2C2C;
+}
+.pos .not-supported-browser .message{
+ width:600px;
+ margin-top: 100px;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+ color: #d3d3d3;
+ font-size: 14px;
+}
+.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;
+}
+
+
+/* Order Management */
+
+.order-row {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-evenly;
+ background-color: rgb(230,230,230);
+}
+
+.order-list .order-row:hover {
+ color: white;
+ background-color: rgb(110,200,155);
+ font-weight: bold;
+}
+
+.order-row.highlight {
+ color: white;
+ background-color: rgb(110,200,155);
+ font-weight: bold;
+}
+
+.order-row.lighter {
+ background-color: #f5f5f5;
+ cursor: pointer;
+}
+
+.order-row .header {
+ font-size: medium;
+ font-weight: bolder;
+ flex-grow: 1;
+ flex-basis: 0;
+ text-align: left;
+ padding: 10px 10px;
+ background-color: #cecece;
+ border-bottom: solid 1px;
+ border-top: solid 1px
+}
+
+.order-row .header.total {
+ text-align: right;
+}
+
+.order-row .item {
+ font-size: medium;
+ flex-grow: 1;
+ flex-basis: 0;
+ text-align: left;
+ padding: 10px 10px;
+ border-bottom: solid rgb(150,150,150) 1px;
+}
+
+.order-row .item.total {
+ text-align: right;
+}
+
+.order-management-screen .flex-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.order-management-screen .orders {
+ display: flex;
+ flex-direction: column;
+}
+
+.order-management-screen .order-list {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.order-management-screen .control-panel {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0.5rem;
+}
+
+.order-management-screen .control-panel .item {
+ font-size: medium;
+}
+
+.order-management-screen .control-panel .item .page-controls {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ font-size: x-large;
+}
+
+.order-management-screen .control-panel .page-controls > div {
+ border: darkgray solid 1px;
+ border-radius: 2px;
+}
+
+.order-management-screen .control-panel .page-controls > div:hover {
+ color: rgb(110,200,155);
+}
+
+.order-management-screen .control-panel .page-controls .previous {
+ margin-right: 0.2rem;
+}
+
+.order-management-screen .control-panel .page-controls .next {
+ margin-left: 0.2rem;
+}
+
+
+.order-management-screen .control-panel .search-box {
+ flex: 1;
+ position: relative;
+ text-align: center;
+ margin: 0.2rem;
+}
+
+.order-management-screen .control-panel .search-box .clear {
+ position: relative;
+ right: 25px;
+ cursor: pointer;
+ color: #808080;
+ font-size: 13px;
+}
+
+.order-management-screen .control-panel .search-box .icon {
+ position: relative;
+ left: 25px;
+ color: #808080;
+ font-size: 13px;
+}
+
+.order-management-screen .control-panel .search-box input {
+ border: 1px solid #cecbcb;
+ padding: 10px 30px;
+ margin: auto;
+ background-color: white;
+ border-radius: 20px;
+ font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial;
+ font-size: 13px;
+}
+
+.order-management-screen .control-panel .search-box input:focus {
+ outline: none;
+ box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset;
+ color: rgb(153, 153, 255);
+}
+
+.order-management-screen .control-panel .button {
+ line-height: 32px;
+ padding: 3px 13px;
+ font-size: 20px;
+ background: rgb(230, 230, 230);
+ border-radius: 3px;
+ border: solid 1px rgb(209, 209, 209);
+ cursor: pointer;
+ transition: all 150ms linear;
+}
+
+.order-management-screen .control-panel .button:hover {
+ background: #efefef;
+}
+
+.order-management-screen .back-to-list {
+ font-size: large;
+ padding: 10px;
+ background-color: #6EC89B;
+ color: white;
+}
+
+.order-receipt {
+ color: white;
+ font-size: medium;
+ text-align: center;
+}
+
+.order-receipt .title {
+ font-size: large;
+}
+
+/* ********* Product Configurator Popup ********* */
+
+.pos .product_configurator_attributes {
+ text-align: left;
+ margin: 1em;
+}
+
+.pos .product_configurator_attributes .attribute {
+ margin-bottom: 1em;
+ display: inline-block;
+ width: 45%;
+ padding-left: 0.5em;
+ vertical-align: top;
+}
+
+@media screen and (max-width: 768px) {
+ .pos .product_configurator_attributes .attribute {
+ width: 95%;
+ }
+}
+
+.pos .product_configurator_attributes .attribute_name {
+ margin-bottom: 0.5em;
+ font-weight: bold;
+}
+
+.pos .product_configurator_attributes input {
+ min-height: 0;
+ width: auto;
+}
+
+/** Radio attribute **/
+
+.pos .product_configurator_attributes .configurator_radio {
+ line-height: 1.5;
+}
+
+.pos .product_configurator_attributes .configurator_radio input[type='radio'] {
+ box-shadow: none;
+ margin-right: 0.5em;
+}
+
+.pos .product_configurator_attributes .configurator_radio .radio_attribute_label {
+ font-weight: normal;
+ display: inline-block;
+ width: 80%;
+}
+
+.pos .product_configurator_attributes .configurator_radio .price_extra {
+ margin-left: 0.5em;
+ padding: 0.2em 0.4em;
+ border-radius: 10rem;
+ color: #FFFFFF;
+ background-color: #6c757d;
+}
+
+.pos .product_configurator_attributes .configurator_radio .custom_value {
+ margin: 0.3em 1.3em;
+}
+
+/** Selector attribute **/
+
+.pos .product_configurator_attributes .configurator_select {
+ cursor: pointer;
+ background-color: transparent;
+ width: 90%;
+ padding: 0.5em;
+ color: #666666;
+ font-size: 18px;
+ margin-bottom: 0.5em;
+}
+
+/** Color attribute **/
+
+.pos .product_configurator_attributes ul.color_attribute_list {
+ margin-left: 0;
+}
+
+.pos .product_configurator_attributes li.color_attribute_list_item:before {
+ content: '';
+}
+
+.pos .product_configurator_attributes li.color_attribute_list_item {
+ margin-bottom: 0.5em;
+ text-indent: 0;
+ display: inline-block;
+}
+
+.pos .product_configurator_attributes .color_attribute_list_item:not(:last-child) {
+ margin-right: 1rem;
+}
+
+.pos .product_configurator_attributes .configurator_color {
+ display: inline-block;
+ border: 1px solid #999999;
+}
+
+.pos .product_configurator_attributes .configurator_color.active {
+ border: 3px ridge #66ee66;
+}
+
+.pos .product_configurator_attributes .configurator_color input {
+ margin: 20px;
+ opacity: 0;
+}
+
+.pos .product_configurator_attributes .configurator_color.active input {
+ margin: 18px;
+}
+
+/* TICKET SCREEN */
+
+.ticket-screen {
+ font-size: medium;
+}
+
+.ticket-screen .orders {
+ display: flex;
+ flex-flow: column nowrap;
+ overflow: hidden;
+ overflow-y: hidden;
+ overflow-y: auto;
+}
+
+.ticket-screen .orders .header-row{
+ display: flex;
+ flex-flow: row nowrap;
+ flex: 1;
+ justify-content: space-evenly;
+ background: #868686;
+ color: white;
+}
+
+.ticket-screen .orders .order-row {
+ display: flex;
+ flex-flow: row nowrap;
+ flex: 1;
+ justify-content: space-evenly;
+}
+
+.ticket-screen .orders .col {
+ display: flex;
+ flex: 1;
+ padding: 10px;
+}
+
+.ticket-screen .orders .col.start {
+ justify-content: flex-start;
+}
+
+.ticket-screen .orders .col.center {
+ justify-content: center;
+}
+
+.ticket-screen .orders .col.end {
+ justify-content: flex-end;
+}
+
+.ticket-screen .orders .col.very-narrow {
+ flex: 0.2;
+}
+
+.ticket-screen .orders .col.narrow {
+ flex: 0.5;
+}
+
+.ticket-screen .orders .col.wide {
+ flex: 1.5;
+}
+
+.ticket-screen .order-row:nth-child(odd) {
+ background: #DDD;
+}
+
+.ticket-screen .order-row:nth-child(even) {
+ background: white;
+}
+
+.ticket-screen .order-row:hover {
+ background: rgb(110,200,155);
+ color: white;
+}
+
+.ticket-screen .pointer {
+ cursor: pointer;
+}
+
+.ticket-screen .controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0px 20px;
+ border-bottom: dashed 1px rgb(215,215,215);
+ flex: 0 0 80px;
+}
+
+.ticket-screen .controls button {
+ font-size: medium;
+ padding: 12px;
+ margin-right: 20px;
+ font-weight: bold;
+}
+
+.ticket-screen .controls button.discard {
+ background: rgb(230, 230, 230);
+ border: solid 1px rgb(209, 209, 209);
+}
+
+.ticket-screen .controls button.highlight {
+ background: #6EC89B;
+ border: solid 1px #64AF8A;
+ color: white;
+}
+
+.ticket-screen .pos-search-bar {
+ vertical-align: middle;
+ white-space: nowrap;
+ position: relative;
+ display: flex;
+ max-width: 500px;
+ flex: 1;
+}
+
+.ticket-screen .pos-search-bar .search {
+ display: flex;
+ position: relative;
+ flex: 1;
+}
+
+.ticket-screen .pos-search-bar .search input {
+ height: 40px;
+ font-size: medium;
+ color: #63717f;
+ padding-left: 40px;
+ border: solid 1px rgb(209, 209, 209);
+ flex: 1;
+}
+
+.ticket-screen .pos-search-bar .radius-right {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+
+.ticket-screen .pos-search-bar .radius-left {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+}
+
+.ticket-screen .pos-search-bar .search input:focus {
+ outline: none;
+}
+
+.ticket-screen .pos-search-bar .search .search-icon {
+ position: absolute;
+ left: 15px;
+ top: 12px;
+ z-index: 1;
+ color: #4f5b66;
+}
+
+.ticket-screen .pos-search-bar .search ul {
+ background: white;
+ position: absolute;
+ top: calc(100% + 5px);
+ right: 2px;
+ left: 2px;
+ box-shadow: 1px 1px 3px grey;
+ font-size: small;
+}
+
+.ticket-screen .pos-search-bar .search li {
+ color: rgb(1,160,157);
+ margin: 0.2em 0;
+ padding-top: 0.2em;
+ padding-bottom: 0.2em;
+ padding-left: 35px;
+}
+
+.ticket-screen .pos-search-bar .search li:hover {
+ background: #DDD;
+}
+
+.ticket-screen .pos-search-bar .search li .field {
+ font-style: italic;
+}
+
+.ticket-screen .pos-search-bar .search li .term {
+ font-weight: bold;
+}
+
+.ticket-screen .pos-search-bar .search li.highlight {
+ background: #DDD;
+}
+
+.ticket-screen .pos-search-bar .filter .down-icon {
+ position: absolute;
+ right: 13px;
+ top: 10px;
+}
+
+.ticket-screen .pos-search-bar .filter {
+ height: 40px;
+ background: white;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ padding-left: 20px;
+ padding-right: 40px;
+ border: solid 1px rgb(209, 209, 209);
+ border-left: none;
+ position: relative;
+ display: flex;
+ align-items: center;
+ max-width: 150px;
+ font-size: medium;
+}
+
+.ticket-screen .pos-search-bar .filter:hover {
+ color: #868686;
+}
+
+.ticket-screen .pos-search-bar .filter .options {
+ display: block;
+ position: absolute;
+ top: calc(100% + 5px);
+ right: 0;
+ z-index: 1;
+ box-shadow: 1px 1px 5px grey;
+ padding: 0.5em 0;
+ background: white;
+ color: #555555;
+}
+
+.ticket-screen .pos-search-bar .filter ul.options li {
+ padding: 0.2em 1.2em;
+ border-top: none;
+ display: flex;
+ justify-content: start;
+ align-items: center;
+}
+
+.ticket-screen .pos-search-bar .filter ul.options li:hover {
+ background-color: #DDD;
+}
+
+.ticket-screen .pos-search-bar .search {
+ display: flex;
+}
+
+.ticket-button {
+ display: flex;
+ align-items: center;
+ padding: 0 15px;
+ font-size: medium;
+ color: white;
+}
+
+.ticket-button.highlight {
+ background: rgb(104,69,95);
+}
+
+.ticket-button:hover {
+ background: rgb(104,69,95);
+ cursor: pointer;
+}
+
+.ticket-button .with-badge {
+ margin-right: 0.7em;
+ font-size: larger;
+}
+
+[badge] {
+ position: relative;
+}
+
+[badge]:after {
+ background: rgb(1,160,157);
+ border-radius: 10rem;
+ color: #fff;
+ content: attr(badge);
+ font-size: small;
+ min-width: 20px;
+ padding: 2px;
+ position: absolute;
+ text-align: center;
+ left: 0.6em;
+ bottom: 0.6em;
+}
+
+[badge^="-"]:after,
+[badge="0"]:after,
+[badge=""]:after {
+ display: none;
+}
+
+/* Product Screen Search Bar */
+
+.search-bar-portal {
+ display: flex;
+}
+
+.search-bar-portal .search-box {
+ font-size: medium;
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin: 0 15px;
+}
+
+.search-bar-portal .search-box input {
+ font-size: medium;
+ height: 30px;
+ border: none;
+ color: #63717f;
+ padding-left: 38px;
+ padding-right: 38px;
+ border-radius: 5px;
+}
+
+.search-bar-portal .search-box input:focus {
+ outline: none;
+}
+
+.search-bar-portal .search-box .icon {
+ position: absolute;
+ left: 0;
+ margin-left: 12px;
+ z-index: 1;
+ color: #4f5b66;
+}
+
+.search-bar-portal .search-box .clear-icon {
+ position: absolute;
+ right: 0;
+ margin-right: 12px;
+ z-index: 1;
+ color: #4f5b66;
+ cursor: pointer;
+}
+
+.cashbox-input {
+ margin: auto;
+ line-height: 74px;
+ font-size: 16px;
+ border: solid 1px rgb(202, 202, 202);
+ border-top-width: 0px;
+ cursor: pointer;
+ padding-left: 30px;
+ padding-right: 30px;
+ flex-grow: 1;
+ flex-basis: 100%;
+}
+
+.currencyCashBox {
+ font-size: 30px;
+}
+
+.receipt-screen .default-view {
+ display: flex;
+ overflow: hidden;
+}
+
+.receipt-screen .default-view .pos-receipt-container {
+ flex: 0 1 400px;
+ overflow: auto;
+}
+
+.receipt-screen .default-view .actions {
+ flex: 1;
+ margin: 0 1.5rem;
+}
+
+.receipt-screen .default-view .actions * {
+ font-size: 1rem;
+}
+
+.receipt-screen .default-view .actions h1 {
+ font-size: 1.5rem;
+ margin-bottom: 4rem;
+}
+
+.receipt-screen .default-view .actions .buttons {
+ display: flex;
+ margin: 1rem 0px;
+}
+
+.receipt-screen .default-view .actions .buttons .button {
+ flex: 1;
+ border: solid 1px rgb(209, 209, 209);
+ padding: 1rem;
+ text-align: center;
+ border-radius: 3px;
+ cursor: pointer;
+ background: rgb(230, 230, 230);
+}
+
+.receipt-screen .default-view .actions .send-email {
+ display: flex;
+}
+
+.receipt-screen .default-view .actions .send-email .email-icon {
+ padding: 1rem;
+ border: solid 1px rgb(209, 209, 209);
+ border-right: none;
+ border-top-left-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+
+.receipt-screen .default-view .actions .send-email .input-email {
+ display: flex;
+ flex: 1;
+ background: white;
+ border: solid 1px rgb(209, 209, 209);
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+
+.receipt-screen .default-view .actions .send-email input {
+ flex: 1;
+ padding-left: 1rem;
+ border: none;
+ color: #555555;
+}
+
+.receipt-screen .default-view .actions .send-email input:focus {
+ outline: none;
+}
+
+.receipt-screen .default-view .actions .send-email button.send {
+ padding: 1rem 40px;
+ margin: 2px;
+ border: none;
+ background: rgb(230, 230, 230);
+}
+
+.receipt-screen .default-view .actions .send-email button.send.highlight {
+ color: white;
+ background-color: #6EC89B;
+}
+
+.receipt-screen .notice {
+ margin-top: 6px;
+}
+
+.receipt-screen .notice.failed {
+ color: rgb(168, 89, 89);
+}
+
+.receipt-screen .notice.successful {
+ color: #6EC89B;
+}
+
+@media screen and (max-width: 768px) {
+ .receipt-screen .default-view {
+ flex-direction: column-reverse;
+ overflow: auto;
+ }
+ .receipt-screen .default-view .actions {
+ flex: 0;
+ }
+ .receipt-screen .default-view .actions h1 {
+ margin-bottom: 1.5rem;
+ }
+ .receipt-screen .default-view .pos-receipt-container {
+ flex: 1;
+ overflow: visible;
+ }
+}
diff --git a/addons/point_of_sale/static/src/css/pos_receipts.css b/addons/point_of_sale/static/src/css/pos_receipts.css
new file mode 100644
index 00000000..b8ed9af2
--- /dev/null
+++ b/addons/point_of_sale/static/src/css/pos_receipts.css
@@ -0,0 +1,65 @@
+.pos-receipt-print {
+ width: 512px;
+ height: 0;
+ overflow: hidden;
+ position: absolute;
+ left: 0;
+ top: 0;
+ text-align: left;
+ direction: ltr;
+ font-size: 27px;
+ color: #000000;
+}
+
+.pos-receipt .pos-receipt-right-align {
+ float: right;
+}
+
+.pos-receipt .pos-receipt-center-align {
+ text-align: center;
+}
+
+.pos-receipt .pos-receipt-left-padding {
+ padding-left: 2em;
+}
+
+.pos-receipt .pos-receipt-logo {
+ width: 50%;
+ display: block;
+ margin: 0 auto;
+}
+
+.pos-receipt .pos-receipt-contact {
+ text-align: center;
+ font-size: 75%;
+}
+
+.pos-receipt .pos-receipt-order-data {
+ text-align: center;
+ font-size: 75%;
+}
+
+.pos-receipt .pos-receipt-amount {
+ font-size: 125%;
+ padding-left: 6em;
+}
+
+.pos-receipt .pos-receipt-title {
+ font-weight: bold;
+ font-size: 125%;
+ text-align: center;
+}
+
+.pos-receipt .pos-receipt-header {
+ font-size: 125%;
+ text-align: center;
+}
+
+.pos-receipt .pos-order-receipt-cancel {
+ color: red;
+}
+
+.pos-payment-terminal-receipt {
+ text-align: center;
+ font-size: 75%;
+}
diff --git a/addons/point_of_sale/static/src/fonts/Inconsolata.otf b/addons/point_of_sale/static/src/fonts/Inconsolata.otf
new file mode 100644
index 00000000..34888982
--- /dev/null
+++ b/addons/point_of_sale/static/src/fonts/Inconsolata.otf
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/backspace.png b/addons/point_of_sale/static/src/img/backspace.png
new file mode 100644
index 00000000..705051d9
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/backspace.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/bc-arrow-big.png b/addons/point_of_sale/static/src/img/bc-arrow-big.png
new file mode 100644
index 00000000..f845fe68
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/bc-arrow-big.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/bc-arrow.png b/addons/point_of_sale/static/src/img/bc-arrow.png
new file mode 100644
index 00000000..0485c597
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/bc-arrow.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/blocks/block_simple_text.png b/addons/point_of_sale/static/src/img/blocks/block_simple_text.png
new file mode 100644
index 00000000..7099744d
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/blocks/block_simple_text.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/default_category_photo.png b/addons/point_of_sale/static/src/img/default_category_photo.png
new file mode 100644
index 00000000..25af75ee
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/default_category_photo.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/home.png b/addons/point_of_sale/static/src/img/home.png
new file mode 100644
index 00000000..53d0b22d
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/home.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/ios-share-icon.png b/addons/point_of_sale/static/src/img/ios-share-icon.png
new file mode 100644
index 00000000..8588657e
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/ios-share-icon.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/logo.png b/addons/point_of_sale/static/src/img/logo.png
new file mode 100644
index 00000000..5bcb128d
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/logo.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/pos_screenshot.jpg b/addons/point_of_sale/static/src/img/pos_screenshot.jpg
new file mode 100644
index 00000000..1e884eb8
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/pos_screenshot.jpg
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/scroll-down.png b/addons/point_of_sale/static/src/img/scroll-down.png
new file mode 100644
index 00000000..5fd07589
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/scroll-down.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/scroll-up.png b/addons/point_of_sale/static/src/img/scroll-up.png
new file mode 100644
index 00000000..b34a9001
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/scroll-up.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon-128.png b/addons/point_of_sale/static/src/img/touch-icon-128.png
new file mode 100644
index 00000000..5bbf31d6
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon-128.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon-196.png b/addons/point_of_sale/static/src/img/touch-icon-196.png
new file mode 100644
index 00000000..dbde2d99
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon-196.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png b/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png
new file mode 100644
index 00000000..4f1e1db5
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon-ipad.png b/addons/point_of_sale/static/src/img/touch-icon-ipad.png
new file mode 100644
index 00000000..8b8c1114
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon-ipad.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png b/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png
new file mode 100644
index 00000000..593c1506
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon-iphone.png b/addons/point_of_sale/static/src/img/touch-icon-iphone.png
new file mode 100644
index 00000000..4122e61f
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon-iphone.png
Binary files differ
diff --git a/addons/point_of_sale/static/src/img/touch-icon.svg b/addons/point_of_sale/static/src/img/touch-icon.svg
new file mode 100644
index 00000000..8ce5f30e
--- /dev/null
+++ b/addons/point_of_sale/static/src/img/touch-icon.svg
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ id="svg3162"
+ version="1.1"
+ inkscape:version="0.48.3.1 r9886"
+ width="152"
+ height="152"
+ sodipodi:docname="ios7-icon.png"
+ inkscape:export-filename="/home/fva/Code/openerp/point_of_sale/touch-icon-ipad-retina.png"
+ inkscape:export-xdpi="90"
+ inkscape:export-ydpi="90">
+ <metadata
+ id="metadata3168">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs3166">
+ <linearGradient
+ id="linearGradient3944">
+ <stop
+ style="stop-color:#483c98;stop-opacity:1;"
+ offset="0"
+ id="stop3946" />
+ <stop
+ style="stop-color:#8075c9;stop-opacity:1;"
+ offset="1"
+ id="stop3948" />
+ </linearGradient>
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3944"
+ id="linearGradient3950"
+ x1="116.83051"
+ y1="0.49999994"
+ x2="115.35169"
+ y2="227.45763"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(0.67555556,0,0,0.67555556,0,-0.67555559)" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1111"
+ id="namedview3164"
+ showgrid="true"
+ inkscape:zoom="1"
+ inkscape:cx="39.575132"
+ inkscape:cy="237.57664"
+ inkscape:window-x="0"
+ inkscape:window-y="27"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg3162">
+ <inkscape:grid
+ type="xygrid"
+ id="grid3942"
+ empspacing="5"
+ visible="true"
+ enabled="true"
+ snapvisiblegridlinesonly="true" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:url(#linearGradient3950);fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect3172"
+ width="152"
+ height="152"
+ x="0"
+ y="-3.9968029e-15"
+ ry="34.723557" />
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke:none"
+ id="rect3952"
+ width="5"
+ height="80"
+ x="25"
+ y="37"
+ ry="1" />
+ <rect
+ ry="1"
+ y="37"
+ x="35"
+ height="80"
+ width="3.0532093"
+ id="rect3954"
+ style="fill:#ffffff;fill-opacity:1;stroke:none" />
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke:none"
+ id="rect3956"
+ width="5.0000033"
+ height="80"
+ x="45"
+ y="37"
+ ry="1" />
+ <rect
+ ry="1"
+ y="37"
+ x="54.999996"
+ height="80"
+ width="3.0000036"
+ id="rect3958"
+ style="fill:#ffffff;fill-opacity:1;stroke:none" />
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke:none"
+ id="rect3960"
+ width="3.0000036"
+ height="80"
+ x="65"
+ y="37"
+ ry="1" />
+ <rect
+ ry="1"
+ y="37"
+ x="75"
+ height="80"
+ width="3.0000036"
+ id="rect3962"
+ style="fill:#ffffff;fill-opacity:1;stroke:none" />
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke:none"
+ id="rect3964"
+ width="5.0000057"
+ height="80"
+ x="80"
+ y="37"
+ ry="1" />
+ <rect
+ ry="1"
+ y="37"
+ x="90"
+ height="80"
+ width="3.0000036"
+ id="rect3966"
+ style="fill:#ffffff;fill-opacity:1;stroke:none" />
+ <rect
+ ry="1"
+ y="37"
+ x="100.02039"
+ height="80"
+ width="5.0000057"
+ id="rect3968"
+ style="fill:#ffffff;fill-opacity:1;stroke:none" />
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke:none"
+ id="rect3970"
+ width="3.0000036"
+ height="80"
+ x="107"
+ y="37"
+ ry="1" />
+ <rect
+ ry="1"
+ y="37"
+ x="114.99999"
+ height="80"
+ width="5.0000057"
+ id="rect3972"
+ style="fill:#ffffff;fill-opacity:1;stroke:none" />
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke:none"
+ id="rect3974"
+ width="3.0532093"
+ height="80"
+ x="122"
+ y="37"
+ ry="1" />
+ <rect
+ style="fill:#f80000;fill-opacity:1;stroke:none"
+ id="rect3976"
+ width="2.0000024"
+ height="110"
+ x="-103.85593"
+ y="20"
+ ry="1.375"
+ transform="matrix(0,-1,1,0,0,0)" />
+</svg>
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 00000000..63f5c363
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Chrome.js
@@ -0,0 +1,454 @@
+odoo.define('point_of_sale.Chrome', function(require) {
+ 'use strict';
+
+ const { useState, useRef, useContext } = 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');
+ const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
+ const contexts = require('point_of_sale.PosContext');
+
+ // 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.chromeContext = useContext(contexts.chrome);
+
+ 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 = {
+ env: this.env,
+ 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._closeOtherTabs();
+ 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();
+ if (_.isEmpty(this.env.pos.db.product_by_category_id)) {
+ this._loadDemoData();
+ }
+ setTimeout(() => {
+ // push order in the background, no need to await
+ this.env.pos.push_orders();
+ // Allow using the app even if not all the images are loaded.
+ // Basically, preload the images in the background.
+ 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();
+ }
+ __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 = {} } }) {
+ const component = this.constructor.components[name];
+ // 1. Set the information of the screen to display.
+ this.mainScreen.name = name;
+ this.mainScreen.component = component;
+ this.mainScreenProps = props;
+
+ // 2. Set some options
+ this.chromeContext.showOrderSelector = !component.hideOrderSelector;
+
+ // 3. Save the screen to the order.
+ // - This screen is shown when the order is selected.
+ if (!(component.prototype instanceof IndependentToOrderScreen) && name !== "ReprintReceiptScreen") {
+ 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({ 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;
+ }
+ }
+
+ get isTicketScreenShown() {
+ return this.mainScreen.name === 'TicketScreen';
+ }
+
+ // MISC METHODS //
+
+ async _loadDemoData() {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('You do not have any products'),
+ body: this.env._t(
+ 'Would you like to load demo data?'
+ ),
+ });
+ if (confirmed) {
+ await this.rpc({
+ 'route': '/pos/load_onboarding_data',
+ });
+ this.env.pos.load_server_data();
+ }
+ }
+
+ _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.hasBigScrollBars = true;
+ }
+
+ 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_warning: function (error) {
+ if (self.env.pos) {
+ // self == this component
+ self.showPopup('ErrorPopup', {
+ title: error.data.title.toString(),
+ body: error.data.message,
+ });
+ } else {
+ // this == CrashManager instance
+ this._super(error);
+ }
+ },
+ 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);
+ }
+ },
+ });
+ }
+ // prevent backspace from performing a 'back' navigation
+ _disableBackspaceBack() {
+ $(document).on('keydown', function (e) {
+ if (e.which === 8 && !$(e.target).is('input, textarea')) {
+ e.preventDefault();
+ }
+ });
+ }
+ _closeOtherTabs() {
+ localStorage['message'] = '';
+ localStorage['message'] = JSON.stringify({
+ message: 'close_tabs',
+ session: this.env.pos.pos_session.id,
+ });
+
+ window.addEventListener(
+ 'storage',
+ (event) => {
+ if (event.key === 'message' && event.newValue) {
+ const msg = JSON.parse(event.newValue);
+ if (
+ msg.message === 'close_tabs' &&
+ msg.session == this.env.pos.pos_session.id
+ ) {
+ console.info(
+ 'POS / Session opened in another window. EXITING POS'
+ );
+ this._closePos();
+ }
+ }
+ },
+ false
+ );
+ }
+ }
+ 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 00000000..02e61967
--- /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 00000000..38403b58
--- /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 00000000..f5158428
--- /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 00000000..84036ecb
--- /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/OrderManagementButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js
new file mode 100644
index 00000000..0bee8880
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js
@@ -0,0 +1,36 @@
+odoo.define('point_of_sale.OrderManagementButton', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { isRpcError } = require('point_of_sale.utils');
+
+ class OrderManagementButton extends PosComponent {
+ async onClick() {
+ try {
+ // ping the server, if no error, show the screen
+ await this.rpc({
+ model: 'pos.order',
+ method: 'browse',
+ args: [[]],
+ kwargs: { context: this.env.session.user_context },
+ });
+ this.showScreen('OrderManagementScreen');
+ } catch (error) {
+ if (isRpcError(error) && error.message.code < 0) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Network Error'),
+ body: this.env._t('Cannot access order management screen if offline.'),
+ });
+ } else {
+ throw error;
+ }
+ }
+ }
+ }
+ OrderManagementButton.template = 'OrderManagementButton';
+
+ Registries.Component.add(OrderManagementButton);
+
+ return OrderManagementButton;
+});
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 00000000..98c24c02
--- /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 00000000..e646547e
--- /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 00000000..5a4e158d
--- /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/ChromeWidgets/TicketButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js
new file mode 100644
index 00000000..d142bbde
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js
@@ -0,0 +1,41 @@
+odoo.define('point_of_sale.TicketButton', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { posbus } = require('point_of_sale.utils');
+
+ class TicketButton extends PosComponent {
+ onClick() {
+ if (this.props.isTicketScreenShown) {
+ posbus.trigger('ticket-button-clicked');
+ } else {
+ this.showScreen('TicketScreen');
+ }
+ }
+ willPatch() {
+ posbus.off('order-deleted', this);
+ }
+ patched() {
+ posbus.on('order-deleted', this, this.render);
+ }
+ mounted() {
+ posbus.on('order-deleted', this, this.render);
+ }
+ willUnmount() {
+ posbus.off('order-deleted', this);
+ }
+ get count() {
+ if (this.env.pos) {
+ return this.env.pos.get_order_list().length;
+ } else {
+ return 0;
+ }
+ }
+ }
+ TicketButton.template = 'TicketButton';
+
+ Registries.Component.add(TicketButton);
+
+ return TicketButton;
+});
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 00000000..eed07fe3
--- /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, B1);
+ * // |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 00000000..1e820782
--- /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/ControlButtonsMixin.js b/addons/point_of_sale/static/src/js/ControlButtonsMixin.js
new file mode 100644
index 00000000..02b4c367
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/ControlButtonsMixin.js
@@ -0,0 +1,84 @@
+odoo.define('point_of_sale.ControlButtonsMixin', function (require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * Component that has this mixin allows the use of `addControlButton`.
+ * All added control buttons that satisfies the condition can be accessed
+ * thru the `controlButtons` field of the Component's instance. These
+ * control buttons can then be rendered in the Component.
+ * @param {Function} x superclass
+ */
+ const ControlButtonsMixin = (x) => {
+ class Extended extends x {
+ get controlButtons() {
+ return this.constructor.controlButtons
+ .filter((cb) => {
+ return cb.condition.bind(this)();
+ })
+ .map((cb) =>
+ Object.assign({}, cb, { component: Registries.Component.get(cb.component) })
+ );
+ }
+ }
+ Extended.controlButtons = [];
+ /**
+ * @param {Object} controlButton
+ * @param {Function} controlButton.component
+ * Base class 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
+ */
+ Extended.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);
+ }
+ }
+ };
+ return Extended;
+ };
+
+ return ControlButtonsMixin;
+});
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 00000000..0720b397
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Gui.js
@@ -0,0 +1,60 @@
+odoo.define('point_of_sale.Gui', function (require) {
+ 'use strict';
+
+ /**
+ * This module bridges the data classes (such as those defined in
+ * models.js) to the view (owl.Component) but not vice versa.
+ *
+ * The idea is to be able to perform side-effects to the user interface
+ * during calculation. Think of console.log during times we want to see
+ * the result of calculations. This is no different, except that instead
+ * of printing something in the console, we access a method in the user
+ * interface then the user interface reacts, e.g. calling `showPopup`.
+ *
+ * This however can be dangerous to the user interface as it can be possible
+ * that a rendered component is destroyed during the calculation. Because of
+ * this, we are going to limit external ui controls to those safe ones to
+ * use such as:
+ * - `showPopup`
+ * - `showTempScreen`
+ *
+ * IMPROVEMENT: After all, this Gui layer seems to be a good abstraction because
+ * there is a complete decoupling between data and view despite the data being
+ * able to use selected functionalities in the view layer. More formalized
+ * implementation is welcome.
+ */
+
+ const config = {};
+
+ /**
+ * Call this when the user interface is ready. Provide the component
+ * that will be used to control the ui.
+ * @param {owl.component} component component having the ui methods.
+ */
+ const configureGui = ({ component }) => {
+ config.component = component;
+ config.availableMethods = new Set([
+ 'showPopup',
+ 'showTempScreen',
+ 'playSound',
+ 'setSyncStatus',
+ ]);
+ };
+
+ /**
+ * Import this and consume like so: `Gui.showPopup(<PopupName>, <props>)`.
+ * Like you would call `showPopup` in a component.
+ */
+ const Gui = new Proxy(config, {
+ get(target, key) {
+ const { component, availableMethods } = target;
+ if (!component) throw new Error(`Call 'configureGui' before using Gui.`);
+ const isMounted = component.__owl__.status === 3 /* mounted */;
+ if (availableMethods.has(key) && isMounted) {
+ return component[key].bind(component);
+ }
+ },
+ });
+
+ return { configureGui, Gui };
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js
new file mode 100644
index 00000000..2ebdce20
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js
@@ -0,0 +1,62 @@
+odoo.define('point_of_sale.AbstractReceiptScreen', function (require) {
+ 'use strict';
+
+ const { useRef } = owl.hooks;
+ const { nextFrame } = require('point_of_sale.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * This relies on the assumption that there is a reference to
+ * `order-receipt` so it is important to declare a `t-ref` to
+ * `order-receipt` in the template of the Component that extends
+ * this abstract component.
+ */
+ class AbstractReceiptScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.orderReceipt = useRef('order-receipt');
+ }
+ 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) {
+ return true;
+ } else {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: printResult.message.title,
+ body: 'Do you want to print using the web printer?',
+ });
+ if (confirmed) {
+ // We want to call the _printWeb when the popup is fully gone
+ // from the screen which happens after the next animation frame.
+ await nextFrame();
+ return await this._printWeb();
+ }
+ return false;
+ }
+ } else {
+ return await this._printWeb();
+ }
+ }
+ async _printWeb() {
+ try {
+ window.print();
+ return true;
+ } catch (err) {
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Printing is not supported on some browsers'),
+ body: this.env._t(
+ 'Printing is not supported on some browsers due to no default printing protocol ' +
+ 'is available. It is possible to print your tickets by making use of an IoT Box.'
+ ),
+ });
+ return false;
+ }
+ }
+ }
+
+ Registries.Component.add(AbstractReceiptScreen);
+
+ return AbstractReceiptScreen;
+});
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 00000000..cbb1eba8
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/Draggable.js
@@ -0,0 +1,142 @@
+odoo.define('point_of_sale.Draggable', function(require) {
+ 'use strict';
+
+ const { useExternalListener } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * Wrap an element or a component with { position: absolute } to make it
+ * draggable around the limitArea or the nearest positioned ancestor.
+ *
+ * e.g.
+ * ```
+ * <div class="limit-area">
+ * <Draggable limitArea="'.limit-area'">
+ * <div class="popup">
+ * <header class="drag-handle"></header>
+ * </div>
+ * <div class="popup body"></div>
+ * </Draggable>
+ * </div>
+ * ```
+ *
+ * In the above snippet, if the popup div is { position: absolute },
+ * then it becomes draggable around the .limit-area element if it is dragged
+ * thru its Header -- because of the .drag-handle element.
+ *
+ * @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }`
+ */
+ class Draggable extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.isDragging = false;
+ this.dx = 0;
+ this.dy = 0;
+ // drag with mouse
+ useExternalListener(document, 'mousemove', this.move);
+ useExternalListener(document, 'mouseup', this.endDrag);
+ // drag with touch
+ useExternalListener(document, 'touchmove', this.move);
+ useExternalListener(document, 'touchend', this.endDrag);
+
+ useListener('mousedown', '.drag-handle', this.startDrag);
+ useListener('touchstart', '.drag-handle', this.startDrag);
+ }
+ mounted() {
+ this.limitArea = this.props.limitArea
+ ? document.querySelector(this.props.limitArea)
+ : this.el.offsetParent;
+ this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect();
+ if (this.limitArea === this.el.offsetParent) {
+ this.limitLeft = 0;
+ this.limitTop = 0;
+ this.limitRight = this.limitAreaBoundingRect.width;
+ this.limitBottom = this.limitAreaBoundingRect.height;
+ } else {
+ this.limitLeft = -this.el.offsetParent.offsetLeft;
+ this.limitTop = -this.el.offsetParent.offsetTop;
+ this.limitRight =
+ this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft;
+ this.limitBottom =
+ this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop;
+ }
+ this.limitAreaWidth = this.limitAreaBoundingRect.width;
+ this.limitAreaHeight = this.limitAreaBoundingRect.height;
+
+ // absolutely position the element then remove the transform.
+ const elBoundingRect = this.el.getBoundingClientRect();
+ this.el.style.top = `${elBoundingRect.top}px`;
+ this.el.style.left = `${elBoundingRect.left}px`;
+ this.el.style.transform = 'none';
+ }
+ startDrag(event) {
+ let realEvent;
+ if (event instanceof CustomEvent) {
+ realEvent = event.detail;
+ } else {
+ realEvent = event;
+ }
+ const { x, y } = this._getEventLoc(realEvent);
+ this.isDragging = true;
+ this.dx = this.el.offsetLeft - x;
+ this.dy = this.el.offsetTop - y;
+ event.stopPropagation();
+ }
+ move(event) {
+ if (this.isDragging) {
+ const { x: pointerX, y: pointerY } = this._getEventLoc(event);
+ const posLeft = this._getPosLeft(pointerX, this.dx);
+ const posTop = this._getPosTop(pointerY, this.dy);
+ this.el.style.left = `${posLeft}px`;
+ this.el.style.top = `${posTop}px`;
+ }
+ }
+ 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/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js
new file mode 100644
index 00000000..e2f2148b
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.IndependentToOrderScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+
+ class IndependentToOrderScreen extends PosComponent {
+ /**
+ * Alias the forceTriggerSelectedOrder method as it also
+ * means 'closing' this screen.
+ */
+ close() {
+ this.forceTriggerSelectedOrder();
+ }
+ forceTriggerSelectedOrder() {
+ // Calling this method forcefully trigger change
+ // on the selectedOrder attribute, which then shows the screen of the
+ // current order, essentially closing this screen.
+ this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order());
+ }
+ }
+
+ return IndependentToOrderScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js
new file mode 100644
index 00000000..024a77b3
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.MobileOrderWidget', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class MobileOrderWidget extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.pane = this.props.pane;
+ this.update();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ mounted() {
+ this.order.on('change', () => {
+ this.update();
+ this.render();
+ });
+ this.order.orderlines.on('change', () => {
+ this.update();
+ this.render();
+ });
+ }
+ update() {
+ const total = this.order ? this.order.get_total_with_tax() : 0;
+ const tax = this.order ? total - this.order.get_total_without_tax() : 0;
+ this.total = this.env.pos.format_currency(total);
+ this.items_number = this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0;
+ }
+ }
+
+ MobileOrderWidget.template = 'MobileOrderWidget';
+
+ Registries.Component.add(MobileOrderWidget);
+
+ return MobileOrderWidget;
+});
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 00000000..540e84f1
--- /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 00000000..8e25f601
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js
@@ -0,0 +1,297 @@
+odoo.define('point_of_sale.NumberBuffer', function(require) {
+ 'use strict';
+
+ const { Component } = owl;
+ const { EventBus } = owl.core;
+ const { onMounted, onWillUnmount, useExternalListener } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const { parse } = require('web.field_utils');
+ const { BarcodeEvents } = require('barcodes.BarcodeEvents');
+ const { _t } = require('web.core');
+ const { Gui } = require('point_of_sale.Gui');
+
+ const INPUT_KEYS = new Set(
+ ['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split(''))
+ );
+ const CONTROL_KEYS = new Set(['Enter', 'Esc']);
+ const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]);
+ const getDefaultConfig = () => ({
+ decimalPoint: false,
+ triggerAtEnter: false,
+ triggerAtEsc: false,
+ triggerAtInput: false,
+ nonKeyboardInputEvent: false,
+ useWithBarcode: false,
+ });
+
+ /**
+ * This is a singleton.
+ *
+ * Only one component can `use` the buffer at a time.
+ * This is done by keeping track of each component (and its
+ * corresponding state and config) using a stack (bufferHolderStack).
+ * The component on top of the stack is the one that currently
+ * `holds` the buffer.
+ *
+ * When the current component is unmounted, the top of the stack
+ * is popped and NumberBuffer is set up again for the new component
+ * on top of the stack.
+ *
+ * Usage
+ * =====
+ * - Activate in the construction of root component. `NumberBuffer.activate()`
+ * - Use the buffer in a child component by calling `NumberBuffer.use(<config>)`
+ * in the constructor of the child component.
+ * - The component that `uses` the buffer has access to the following instance
+ * methods of the NumberBuffer:
+ * - get()
+ * - set(val)
+ * - reset()
+ * - getFloat()
+ * - capture()
+ *
+ * Note
+ * ====
+ * - No need to instantiate as it is a singleton 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.
+ * - Write more integration tests. NumberPopup can be used as test component.
+ */
+ 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);
+ }
+ /**
+ * Calling this function, we immediately invoke the `handler` method
+ * that handles the contents of the input events buffer (`eventsBuffer`).
+ * This is helpful when we don't want to wait for the timeout that
+ * is supposed to invoke the handler.
+ */
+ capture() {
+ if (this.handler) {
+ clearTimeout(this._timeout);
+ this.handler();
+ delete this.handler;
+ }
+ }
+ /**
+ * @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.triggerAtEsc Event triggered when 'Esc' 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.
+ * @emits config.triggerAtEnter when 'Enter' key is pressed.
+ * @emits config.triggerAtEsc when 'Esc' key is pressed.
+ * @emits config.triggerAtInput when an input is accepted.
+ */
+ use(config) {
+ this.eventsBuffer = [];
+ const currentComponent = Component.current;
+ config = Object.assign(getDefaultConfig(), config);
+ onMounted(() => {
+ this.bufferHolderStack.push({
+ component: currentComponent,
+ state: config.state ? config.state : { 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);
+ this.handler = handler
+ };
+ }
+ _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 === ',' || input === '.') {
+ if (isFirstInput) {
+ this.state.buffer = '0' + this.decimalPoint;
+ } else if (!this.state.buffer.length || this.state.buffer === '-') {
+ this.state.buffer += '0' + this.decimalPoint;
+ } 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/Misc/SearchBar.js b/addons/point_of_sale/static/src/js/Misc/SearchBar.js
new file mode 100644
index 00000000..e9f56fea
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/SearchBar.js
@@ -0,0 +1,115 @@
+odoo.define('point_of_sale.SearchBar', function (require) {
+ 'use strict';
+
+ const { useState, useExternalListener } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * This is a simple configurable search bar component. It has search fields
+ * and selection filter. Search fields allow the users to specify the type
+ * of their searches. The filter is a dropdown menu for selection. Depending on
+ * user's action, this component emits corresponding event with the action
+ * information (payload).
+ *
+ * TODO: This component can be made more generic and be able to replace
+ * all the search bars across pos ui.
+ *
+ * @prop {{
+ * config: {
+ * searchFields: string[],
+ * filter: { show: boolean, options: string[] }
+ * },
+ * placeholder: string,
+ * }}
+ * @emits search @payload { fieldValue: string, searchTerm: '' }
+ * @emits filter-selected @payload { filter: string }
+ *
+ * NOTE: The payload of the emitted event is accessible via the `detail`
+ * field of the event.
+ */
+ class SearchBar extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.config = this.props.config;
+ this.state = useState({
+ searchInput: '',
+ selectedFieldId: this.config.searchFields.length ? 0 : null,
+ showSearchFields: false,
+ showFilterOptions: false,
+ selectedFilter: this.config.filter.options[0] || this.env._t('Select'),
+ });
+ useExternalListener(window, 'click', this._hideOptions);
+ }
+ selectFilter(option) {
+ this.state.selectedFilter = option;
+ this.trigger('filter-selected', { filter: this.state.selectedFilter });
+ }
+ get placeholder() {
+ return this.props.placeholder;
+ }
+ /**
+ * When vertical arrow keys are pressed, select fields for searching.
+ * When enter key is pressed, trigger search event if there is searchInput.
+ */
+ onKeydown(event) {
+ if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
+ event.preventDefault();
+ this.state.selectedFieldId = this._fieldIdToSelect(event.key);
+ } else if (event.key === 'Enter') {
+ this.trigger('search', {
+ fieldValue: this.config.searchFields[this.state.selectedFieldId],
+ searchTerm: this.state.searchInput,
+ });
+ this.state.showSearchFields = false;
+ } else {
+ if (this.state.selectedFieldId === null && this.config.searchFields.length) {
+ this.state.selectedFieldId = 0;
+ }
+ this.state.showSearchFields = true;
+ }
+ }
+ /**
+ * Called when a search field is clicked.
+ */
+ onClickSearchField(id) {
+ this.state.showSearchFields = false;
+ this.trigger('search', {
+ fieldValue: this.config.searchFields[id],
+ searchTerm: this.state.searchInput,
+ });
+ }
+ /**
+ * Given an arrow key, return the next selectedFieldId.
+ * E.g. If the selectedFieldId is 1 and ArrowDown is pressed, return 2.
+ *
+ * @param {string} key vertical arrow key
+ */
+ _fieldIdToSelect(key) {
+ const length = this.config.searchFields.length;
+ if (!length) return null;
+ if (this.state.selectedFieldId === null) return 0;
+ const current = this.state.selectedFieldId || length;
+ return (current + (key === 'ArrowDown' ? 1 : -1)) % length;
+ }
+ _hideOptions() {
+ this.state.showFilterOptions = false;
+ this.state.showSearchFields = false;
+ }
+ }
+ SearchBar.template = 'point_of_sale.SearchBar';
+ SearchBar.defaultProps = {
+ config: {
+ searchFields: [],
+ filter: {
+ show: false,
+ options: [],
+ },
+ },
+ placeholder: 'Search ...',
+ };
+
+ Registries.Component.add(SearchBar);
+
+ return SearchBar;
+});
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 00000000..446a514a
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/PopupControllerMixin.js
@@ -0,0 +1,44 @@
+odoo.define('point_of_sale.PopupControllerMixin', function(require) {
+ 'use strict';
+
+ const { useState } = owl;
+ const { useListener } = require('web.custom_hooks');
+
+ /**
+ * Allows the component declared with this mixin the ability show popup dynamically,
+ * provided the following:
+ * 1. The following element is declared in the template. It is where the Popup will be rendered.
+ * `<t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />`
+ * 2. The component should trigger `show-popup` event to show the popup and `close-popup` event
+ * to close. In PosComponent, `showPopup` is conveniently declared to satisfy this requirement.
+ * @param {Function} x class definition to mix with during extension
+ */
+ const PopupControllerMixin = x =>
+ class extends x {
+ constructor() {
+ super(...arguments);
+ useListener('show-popup', this.__showPopup);
+ useListener('close-popup', this.__closePopup);
+
+ this.popup = useState({ isShown: false, name: null, component: null });
+ this.popupProps = {}; // We want to avoid making the props to become Proxy!
+ }
+ __showPopup(event) {
+ const { name, props, resolve } = event.detail;
+ const popupConstructor = this.constructor.components[name];
+ if (popupConstructor.dontShow) {
+ resolve();
+ return;
+ }
+ this.popup.isShown = true;
+ this.popup.name = name;
+ this.popup.component = popupConstructor;
+ this.popupProps = Object.assign({}, props, { resolve });
+ }
+ __closePopup() {
+ this.popup.isShown = false;
+ }
+ };
+
+ return PopupControllerMixin;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js
new file mode 100644
index 00000000..6cdd6a04
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js
@@ -0,0 +1,60 @@
+odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) {
+ 'use strict';
+
+ const { useExternalListener } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+
+ /**
+ * Implement this abstract class by extending it like so:
+ * ```js
+ * class ConcretePopup extends AbstractAwaitablePopup {
+ * async getPayload() {
+ * return 'result';
+ * }
+ * }
+ * ConcretePopup.template = owl.tags.xml`
+ * <div>
+ * <button t-on-click="confirm">Okay</button>
+ * <button t-on-click="cancel">Cancel</button>
+ * </div>
+ * `
+ * ```
+ *
+ * The concrete popup can now be instantiated and be awaited for
+ * the user's response like so:
+ * ```js
+ * const { confirmed, payload } = await this.showPopup('ConcretePopup');
+ * // based on the implementation above,
+ * // if confirmed, payload = 'result'
+ * // otherwise, payload = null
+ * ```
+ */
+ class AbstractAwaitablePopup extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useExternalListener(window, 'keyup', this._cancelAtEscape);
+ }
+ async confirm() {
+ this.props.resolve({ confirmed: true, payload: await this.getPayload() });
+ this.trigger('close-popup');
+ }
+ cancel() {
+ this.props.resolve({ confirmed: false, payload: null });
+ this.trigger('close-popup');
+ }
+ _cancelAtEscape(event) {
+ if (event.key === 'Escape') {
+ this.cancel();
+ }
+ }
+ /**
+ * Override this in the concrete popup implementation to set the
+ * payload when the popup is confirmed.
+ */
+ async getPayload() {
+ return null;
+ }
+ }
+
+ return AbstractAwaitablePopup;
+});
diff --git a/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js
new file mode 100644
index 00000000..e22c1aaa
--- /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 00000000..09b39f21
--- /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 00000000..ac4b262d
--- /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 00000000..8cf11c40
--- /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 00000000..865779c4
--- /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 00000000..1af25e42
--- /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 00000000..bf63ba8d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js
@@ -0,0 +1,79 @@
+odoo.define('point_of_sale.NumberPopup', function(require) {
+ 'use strict';
+ var core = require('web.core');
+ var _t = core._t;
+
+ 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;
+ }
+ }
+ confirm(event) {
+ const bufferState = event.detail;
+ if (bufferState.buffer !== '') {
+ super.confirm();
+ }
+ }
+ sendInput(key) {
+ this.trigger('numpad-click-input', { key });
+ }
+ getPayload() {
+ return NumberBuffer.get();
+ }
+ }
+ NumberPopup.template = 'NumberPopup';
+ NumberPopup.defaultProps = {
+ confirmText: _t('Ok'),
+ cancelText: _t('Cancel'),
+ title: _t('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 00000000..147ed7c4
--- /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 00000000..c2c35291
--- /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/ProductConfiguratorPopup.js b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js
new file mode 100644
index 00000000..b04e55d8
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js
@@ -0,0 +1,89 @@
+odoo.define('point_of_sale.ProductConfiguratorPopup', function(require) {
+ 'use strict';
+
+ const { useState, useSubEnv } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductConfiguratorPopup extends AbstractAwaitablePopup {
+ constructor() {
+ super(...arguments);
+ useSubEnv({ attribute_components: [] });
+ }
+
+ getPayload() {
+ var selected_attributes = [];
+ var price_extra = 0.0;
+
+ this.env.attribute_components.forEach((attribute_component) => {
+ let { value, extra } = attribute_component.getValue();
+ selected_attributes.push(value);
+ price_extra += extra;
+ });
+
+ return {
+ selected_attributes,
+ price_extra,
+ };
+ }
+ }
+ ProductConfiguratorPopup.template = 'ProductConfiguratorPopup';
+ Registries.Component.add(ProductConfiguratorPopup);
+
+ class BaseProductAttribute extends PosComponent {
+ constructor() {
+ super(...arguments);
+
+ this.env.attribute_components.push(this);
+
+ this.attribute = this.props.attribute;
+ this.values = this.attribute.values;
+ this.state = useState({
+ selected_value: parseFloat(this.values[0].id),
+ custom_value: '',
+ });
+ }
+
+ getValue() {
+ let selected_value = this.values.find((val) => val.id === parseFloat(this.state.selected_value));
+ let value = selected_value.name;
+ if (selected_value.is_custom && this.state.custom_value) {
+ value += `: ${this.state.custom_value}`;
+ }
+
+ return {
+ value,
+ extra: selected_value.price_extra
+ };
+ }
+ }
+
+ class RadioProductAttribute extends BaseProductAttribute {
+ mounted() {
+ // With radio buttons `t-model` selects the default input by searching for inputs with
+ // a matching `value` attribute. In our case, we use `t-att-value` so `value` is
+ // not found yet and no radio is selected by default.
+ // We then manually select the first input of each radio attribute.
+ $(this.el).find('input[type="radio"]:first').prop('checked', true);
+ }
+ }
+ RadioProductAttribute.template = 'RadioProductAttribute';
+ Registries.Component.add(RadioProductAttribute);
+
+ class SelectProductAttribute extends BaseProductAttribute { }
+ SelectProductAttribute.template = 'SelectProductAttribute';
+ Registries.Component.add(SelectProductAttribute);
+
+ class ColorProductAttribute extends BaseProductAttribute {}
+ ColorProductAttribute.template = 'ColorProductAttribute';
+ Registries.Component.add(ColorProductAttribute);
+
+ return {
+ ProductConfiguratorPopup,
+ BaseProductAttribute,
+ RadioProductAttribute,
+ SelectProductAttribute,
+ ColorProductAttribute,
+ };
+});
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 00000000..5321fdea
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js
@@ -0,0 +1,57 @@
+odoo.define('point_of_sale.SelectionPopup', function (require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ // formerly SelectionPopupWidget
+ class SelectionPopup extends AbstractAwaitablePopup {
+ /**
+ * Value of the `item` key of the selected element in the Selection
+ * Array is the payload of this popup.
+ *
+ * @param {Object} props
+ * @param {String} [props.confirmText='Confirm']
+ * @param {String} [props.cancelText='Cancel']
+ * @param {String} [props.title='Select']
+ * @param {String} [props.body='']
+ * @param {Array<Selection>} [props.list=[]]
+ * Selection {
+ * id: integer,
+ * label: string,
+ * isSelected: boolean,
+ * item: any,
+ * }
+ */
+ constructor() {
+ super(...arguments);
+ this.state = useState({ selectedId: this.props.list.find((item) => item.isSelected) });
+ }
+ selectItem(itemId) {
+ this.state.selectedId = itemId;
+ this.confirm();
+ }
+ /**
+ * We send as payload of the response the selected item.
+ *
+ * @override
+ */
+ getPayload() {
+ const selected = this.props.list.find((item) => this.state.selectedId === item.id);
+ 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 00000000..1f2735f6
--- /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 00000000..4a0612d2
--- /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 00000000..ae2873ba
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/PosComponent.js
@@ -0,0 +1,59 @@
+odoo.define('point_of_sale.PosComponent', function (require) {
+ 'use strict';
+
+ const { Component } = owl;
+
+ class PosComponent extends Component {
+ /**
+ * This function is available to all Components that inherit this class.
+ * The goal of this function is to show an awaitable dialog (popup) that
+ * returns a response after user interaction. See the following for quick
+ * demonstration:
+ *
+ * ```
+ * async getUserName() {
+ * const userResponse = await this.showPopup(
+ * 'TextInputPopup',
+ * { title: 'What is your name?' }
+ * );
+ * // at this point, the TextInputPopup is displayed. Depending on how the popup is defined,
+ * // say the input contains the name, the result of the interaction with the user is
+ * // saved in `userResponse`.
+ * console.log(userResponse); // logs { confirmed: true, payload: <name> }
+ * }
+ * ```
+ *
+ * @param {String} name Name of the popup component
+ * @param {Object} props Object that will be used to render to popup
+ */
+ showPopup(name, props) {
+ return new Promise((resolve) => {
+ this.trigger('show-popup', { name, props, resolve });
+ });
+ }
+ showTempScreen(name, props) {
+ return new Promise((resolve) => {
+ this.trigger('show-temp-screen', { name, props, resolve });
+ });
+ }
+ showScreen(name, props) {
+ this.trigger('show-main-screen', { name, props });
+ }
+ /**
+ * @param {String} name 'bell' | 'error'
+ */
+ playSound(name) {
+ this.trigger('play-sound', name);
+ }
+ /**
+ * Control the SyncNotification component.
+ * @param {String} status 'connected' | 'connecting' | 'disconnected' | 'error'
+ * @param {String} pending number of pending orders to sync
+ */
+ setSyncStatus(status, pending) {
+ this.trigger('set-sync-status', { status, pending });
+ }
+ }
+
+ return PosComponent;
+});
diff --git a/addons/point_of_sale/static/src/js/PosContext.js b/addons/point_of_sale/static/src/js/PosContext.js
new file mode 100644
index 00000000..b52a155d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/PosContext.js
@@ -0,0 +1,12 @@
+odoo.define('point_of_sale.PosContext', function (require) {
+ 'use strict';
+
+ const { Context } = owl;
+
+ // Create global context objects
+ // e.g. component.env.device = new Context({ isMobile: false });
+ return {
+ orderManagement: new Context({ searchString: '', selectedOrder: null }),
+ chrome: new Context({ showOrderSelector: true }),
+ };
+});
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 00000000..e95817ab
--- /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/ClientDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js
new file mode 100644
index 00000000..3c126ec2
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js
@@ -0,0 +1,129 @@
+odoo.define('point_of_sale.ClientDetailsEdit', function(require) {
+ 'use strict';
+
+ const { _t } = require('web.core');
+ 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'];
+ const partner = this.props.partner;
+ this.changes = {
+ 'country_id': partner.country_id && partner.country_id[0],
+ 'state_id': partner.state_id && partner.state_id[0],
+ };
+ }
+ mounted() {
+ this.env.bus.on('save-customer', this, this.saveChanges);
+ }
+ willUnmount() {
+ this.env.bus.off('save-customer', this);
+ }
+ 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;
+ }
+ }
+ if ((!this.props.partner.name && !processedChanges.name) ||
+ processedChanges.name === '' ){
+ return this.showPopup('ErrorPopup', {
+ title: _t('A Customer Name Is Required'),
+ });
+ }
+ 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 00000000..86f55645
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js
@@ -0,0 +1,17 @@
+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() {
+ return this.props.partner !== this.props.selectedClient ? '' : 'highlight';
+ }
+ }
+ 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 00000000..4863d588
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js
@@ -0,0 +1,182 @@
+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');
+ const { useListener } = require('web.custom_hooks');
+
+ /**
+ * 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);
+ useListener('click-save', () => this.env.bus.trigger('save-customer'));
+ useListener('click-edit', () => this.editClient());
+ useListener('save-changes', this.saveChanges);
+
+ // 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: false,
+ 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() {
+ if(this.state.detailIsShown) {
+ this.state.detailIsShown = false;
+ this.render();
+ } else {
+ 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 ? true : 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: this.env._t('Set Customer') };
+ } else if (this.props.client && this.props.client === this.state.selectedClient) {
+ return { command: 'deselect', text: this.env._t('Deselect Customer') };
+ } else {
+ return { command: 'set', text: this.env._t('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.selectedClient = null;
+ } else {
+ this.state.selectedClient = partner;
+ }
+ this.render();
+ }
+ editClient() {
+ this.state.editModeProps = {
+ partner: this.state.selectedClient,
+ };
+ this.state.detailIsShown = true;
+ this.render();
+ }
+ 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;
+ this.state.isNewClient = isNewClient;
+ 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.state.detailIsShown = false;
+ this.render();
+ } 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/OrderManagementScreen/ControlButtons/InvoiceButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js
new file mode 100644
index 00000000..53b858ba
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js
@@ -0,0 +1,155 @@
+odoo.define('point_of_sale.InvoiceButton', function (require) {
+ 'use strict';
+
+ const { useListener } = require('web.custom_hooks');
+ const { useContext } = owl.hooks;
+ const { isRpcError } = require('point_of_sale.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
+ const OrderFetcher = require('point_of_sale.OrderFetcher');
+ const Registries = require('point_of_sale.Registries');
+ const contexts = require('point_of_sale.PosContext');
+
+ class InvoiceButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this._onClick);
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ }
+ get selectedOrder() {
+ return this.orderManagementContext.selectedOrder;
+ }
+ set selectedOrder(value) {
+ this.orderManagementContext.selectedOrder = value;
+ }
+ get isAlreadyInvoiced() {
+ if (!this.selectedOrder) return false;
+ return Boolean(this.selectedOrder.account_move);
+ }
+ get commandName() {
+ if (!this.selectedOrder) {
+ return 'Invoice';
+ } else {
+ return this.isAlreadyInvoiced
+ ? 'Reprint Invoice'
+ : this.selectedOrder.isFromClosedSession
+ ? 'Cannot Invoice'
+ : 'Invoice';
+ }
+ }
+ get isHighlighted() {
+ return this.selectedOrder && !this.isAlreadyInvoiced && !this.selectedOrder.isFromClosedSession;
+ }
+ async _downloadInvoice(orderId) {
+ try {
+ await this.env.pos.do_action('point_of_sale.pos_invoice_report', {
+ additional_context: {
+ active_ids: [orderId],
+ },
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ // NOTE: error here is most probably undefined
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Network Error'),
+ body: this.env._t('Unable to download invoice.'),
+ });
+ }
+ }
+ }
+ async _invoiceOrder() {
+ const order = this.selectedOrder;
+ if (!order) return;
+
+ const orderId = order.backendId;
+
+ // Part 0.1. If already invoiced, print the invoice.
+ if (this.isAlreadyInvoiced) {
+ await this._downloadInvoice(orderId);
+ return;
+ }
+
+ // Part 0.2. Check if order belongs to an active session.
+ // If not, do not allow invoicing.
+ if (order.isFromClosedSession) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Session is closed'),
+ body: this.env._t('Cannot invoice order from closed session.'),
+ });
+ return;
+ }
+
+ // Part 1: Handle missing client.
+ // Write to pos.order the selected client.
+ if (!order.get_client()) {
+ const { confirmed: confirmedPopup } = await this.showPopup('ConfirmPopup', {
+ title: 'Need customer to invoice',
+ body: 'Do you want to open the customer list to select customer?',
+ });
+ if (!confirmedPopup) return;
+
+ const { confirmed: confirmedTempScreen, payload: newClient } = await this.showTempScreen(
+ 'ClientListScreen'
+ );
+ if (!confirmedTempScreen) return;
+
+ await this.rpc({
+ model: 'pos.order',
+ method: 'write',
+ args: [[orderId], { partner_id: newClient.id }],
+ kwargs: { context: this.env.session.user_context },
+ });
+ }
+
+ // Part 2: Invoice the order.
+ await this.rpc(
+ {
+ model: 'pos.order',
+ method: 'action_pos_order_invoice',
+ args: [orderId],
+ kwargs: { context: this.env.session.user_context },
+ },
+ {
+ timeout: 30000,
+ shadow: true,
+ }
+ );
+
+ // Part 3: Download invoice.
+ await this._downloadInvoice(orderId);
+
+ // Invalidate the cache then fetch the updated order.
+ OrderFetcher.invalidateCache([orderId]);
+ await OrderFetcher.fetch();
+ this.selectedOrder = OrderFetcher.get(this.selectedOrder.backendId);
+ }
+ async _onClick() {
+ try {
+ await this._invoiceOrder();
+ } catch (error) {
+ if (isRpcError(error) && error.message.code < 0) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Network Error'),
+ body: this.env._t('Unable to invoice order.'),
+ });
+ } else {
+ throw error;
+ }
+ }
+ }
+ }
+ InvoiceButton.template = 'InvoiceButton';
+
+ OrderManagementScreen.addControlButton({
+ component: InvoiceButton,
+ condition: function () {
+ return this.env.pos.config.module_account;
+ },
+ });
+
+ Registries.Component.add(InvoiceButton);
+
+ return InvoiceButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js
new file mode 100644
index 00000000..5a227827
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js
@@ -0,0 +1,36 @@
+odoo.define('point_of_sale.ReprintReceiptButton', function (require) {
+ 'use strict';
+
+ const { useListener } = require('web.custom_hooks');
+ const { useContext } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
+ const Registries = require('point_of_sale.Registries');
+ const contexts = require('point_of_sale.PosContext');
+
+ class ReprintReceiptButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this._onClick);
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ }
+ async _onClick() {
+ const order = this.orderManagementContext.selectedOrder;
+ if (!order) return;
+
+ this.showScreen('ReprintReceiptScreen', { order: order });
+ }
+ }
+ ReprintReceiptButton.template = 'ReprintReceiptButton';
+
+ OrderManagementScreen.addControlButton({
+ component: ReprintReceiptButton,
+ condition: function () {
+ return true;
+ },
+ });
+
+ Registries.Component.add(ReprintReceiptButton);
+
+ return ReprintReceiptButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js
new file mode 100644
index 00000000..b5766ccf
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js
@@ -0,0 +1,25 @@
+odoo.define('point_of_sale.MobileOrderManagementScreen', function (require) {
+ const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+ const { useState } = owl.hooks;
+
+ const MobileOrderManagementScreen = (OrderManagementScreen) => {
+ class MobileOrderManagementScreen extends OrderManagementScreen {
+ constructor() {
+ super(...arguments);
+ useListener('click-order', this._onShowDetails)
+ this.mobileState = useState({ showDetails: false });
+ }
+ _onShowDetails() {
+ this.mobileState.showDetails = true;
+ }
+ }
+ MobileOrderManagementScreen.template = 'MobileOrderManagementScreen';
+ return MobileOrderManagementScreen;
+ };
+
+ Registries.Component.addByExtending(MobileOrderManagementScreen, OrderManagementScreen);
+
+ return MobileOrderManagementScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js
new file mode 100644
index 00000000..cc0c671c
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js
@@ -0,0 +1,29 @@
+odoo.define('point_of_sale.OrderDetails', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props {models.Order} order
+ */
+ class OrderDetails extends PosComponent {
+ get order() {
+ return this.props.order;
+ }
+ get orderlines() {
+ return this.order ? this.order.orderlines.models : [];
+ }
+ get total() {
+ return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0);
+ }
+ get tax() {
+ return this.env.pos.format_currency(this.order ? this.order.get_total_tax() : 0)
+ }
+ }
+ OrderDetails.template = 'OrderDetails';
+
+ Registries.Component.add(OrderDetails);
+
+ return OrderDetails;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js
new file mode 100644
index 00000000..57a02635
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js
@@ -0,0 +1,214 @@
+odoo.define('point_of_sale.OrderFetcher', function (require) {
+ 'use strict';
+
+ const { EventBus } = owl.core;
+ const { Gui } = require('point_of_sale.Gui');
+ const { isRpcError } = require('point_of_sale.utils');
+ const models = require('point_of_sale.models');
+
+ class OrderFetcher extends EventBus {
+ constructor() {
+ super();
+ this.currentPage = 1;
+ this.ordersToShow = [];
+ this.cache = {};
+ this.totalCount = 0;
+ }
+ get activeOrders() {
+ const allActiveOrders = this.comp.env.pos.get('orders').models;
+ return this.searchDomain
+ ? allActiveOrders.filter(this._predicateBasedOnSearchDomain.bind(this))
+ : allActiveOrders;
+ }
+ _predicateBasedOnSearchDomain(order) {
+ function check(order, field, searchWord) {
+ searchWord = searchWord.toLowerCase();
+ switch (field) {
+ case 'pos_reference':
+ return order.name.toLowerCase().includes(searchWord);
+ case 'partner_id.display_name':
+ const client = order.get_client();
+ return client ? client.name.toLowerCase().includes(searchWord) : false;
+ case 'date_order':
+ return moment(order.creation_date).format('YYYY-MM-DD hh:mm A').includes(searchWord);
+ default:
+ return false;
+ }
+ }
+ for (let [field, _, searchWord] of (this.searchDomain || []).filter((item) => item !== '|')) {
+ // remove surrounding "%" from `searchWord`
+ searchWord = searchWord.substring(1, searchWord.length - 1);
+ if (check(order, field, searchWord)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ get nActiveOrders() {
+ return this.activeOrders.length;
+ }
+ get lastPageFullOfActiveOrders() {
+ return Math.trunc(this.nActiveOrders / this.nPerPage);
+ }
+ get remainingActiveOrders() {
+ return this.nActiveOrders % this.nPerPage;
+ }
+ /**
+ * for nPerPage = 10
+ * +--------+----------+
+ * | nItems | lastPage |
+ * +--------+----------+
+ * | 2 | 1 |
+ * | 10 | 1 |
+ * | 11 | 2 |
+ * | 30 | 3 |
+ * | 35 | 4 |
+ * +--------+----------+
+ */
+ get lastPage() {
+ const nItems = this.nActiveOrders + this.totalCount;
+ return Math.trunc(nItems / (this.nPerPage + 1)) + 1;
+ }
+ /**
+ * Calling this methods populates the `ordersToShow` then trigger `update` event.
+ * @related get
+ *
+ * NOTE: This is tightly-coupled with pagination. So if the current page contains all
+ * active orders, it will not fetch anything from the server but only sets `ordersToShow`
+ * to the active orders that fits the current page.
+ */
+ async fetch() {
+ try {
+ let limit, offset;
+ let start, end;
+ if (this.currentPage <= this.lastPageFullOfActiveOrders) {
+ // Show only active orders.
+ start = (this.currentPage - 1) * this.nPerPage;
+ end = this.currentPage * this.nPerPage;
+ this.ordersToShow = this.activeOrders.slice(start, end);
+ } else if (this.currentPage === this.lastPageFullOfActiveOrders + 1) {
+ // Show partially the remaining active orders and
+ // some orders from the backend.
+ offset = 0;
+ limit = this.nPerPage - this.remainingActiveOrders;
+ start = (this.currentPage - 1) * this.nPerPage;
+ end = this.nActiveOrders;
+ this.ordersToShow = [
+ ...this.activeOrders.slice(start, end),
+ ...(await this._fetch(limit, offset)),
+ ];
+ } else {
+ // Show orders from the backend.
+ offset =
+ this.nPerPage -
+ this.remainingActiveOrders +
+ (this.currentPage - (this.lastPageFullOfActiveOrders + 1) - 1) *
+ this.nPerPage;
+ limit = this.nPerPage;
+ this.ordersToShow = await this._fetch(limit, offset);
+ }
+ this.trigger('update');
+ } catch (error) {
+ if (isRpcError(error) && error.message.code < 0) {
+ Gui.showPopup('ErrorPopup', {
+ title: this.comp.env._t('Network Error'),
+ body: this.comp.env._t('Unable to fetch orders if offline.'),
+ });
+ Gui.setSyncStatus('error');
+ } else {
+ throw error;
+ }
+ }
+ }
+ /**
+ * This returns the orders from the backend that needs to be shown.
+ * If the order is already in cache, the full information about that
+ * order is not fetched anymore, instead, we use info from cache.
+ *
+ * @param {number} limit
+ * @param {number} offset
+ */
+ async _fetch(limit, offset) {
+ const { ids, totalCount } = await this._getOrderIdsForCurrentPage(limit, offset);
+ const idsNotInCache = ids.filter((id) => !(id in this.cache));
+ if (idsNotInCache.length > 0) {
+ const fetchedOrders = await this._fetchOrders(idsNotInCache);
+ // Cache these fetched orders so that next time, no need to fetch
+ // them again, unless invalidated. See `invalidateCache`.
+ fetchedOrders.forEach((order) => {
+ this.cache[order.id] = new models.Order(
+ {},
+ { pos: this.comp.env.pos, json: order }
+ );
+ });
+ }
+ this.totalCount = totalCount;
+ return ids.map((id) => this.cache[id]);
+ }
+ async _getOrderIdsForCurrentPage(limit, offset) {
+ return await this.rpc({
+ model: 'pos.order',
+ method: 'search_paid_order_ids',
+ kwargs: { config_id: this.configId, domain: this.searchDomain ? this.searchDomain : [], limit, offset },
+ context: this.comp.env.session.user_context,
+ });
+ }
+ async _fetchOrders(ids) {
+ return await this.rpc({
+ model: 'pos.order',
+ method: 'export_for_ui',
+ args: [ids],
+ context: this.comp.env.session.user_context,
+ });
+ }
+ nextPage() {
+ if (this.currentPage < this.lastPage) {
+ this.currentPage += 1;
+ this.fetch();
+ }
+ }
+ prevPage() {
+ if (this.currentPage > 1) {
+ this.currentPage -= 1;
+ this.fetch();
+ }
+ }
+ /**
+ * @param {integer|undefined} id id of the cached order
+ * @returns {Array<models.Order>}
+ */
+ get(id) {
+ if (id) return this.cache[id];
+ return this.ordersToShow;
+ }
+ setSearchDomain(searchDomain) {
+ this.searchDomain = searchDomain;
+ }
+ setComponent(comp) {
+ this.comp = comp;
+ return this;
+ }
+ setConfigId(configId) {
+ this.configId = configId;
+ }
+ setNPerPage(val) {
+ this.nPerPage = val;
+ }
+ setPage(page) {
+ this.currentPage = page;
+ }
+ invalidateCache(ids) {
+ for (let id of ids) {
+ delete this.cache[id];
+ }
+ }
+ async rpc() {
+ Gui.setSyncStatus('connecting');
+ const result = await this.comp.rpc(...arguments);
+ Gui.setSyncStatus('connected');
+ return result;
+ }
+ }
+
+ return new OrderFetcher();
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js
new file mode 100644
index 00000000..2b4d3cd9
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js
@@ -0,0 +1,31 @@
+odoo.define('point_of_sale.OrderList', function (require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props {models.Order} [initHighlightedOrder] initially highligted order
+ * @props {Array<models.Order>} orders
+ */
+ class OrderList extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click-order', this._onClickOrder);
+ this.state = useState({ highlightedOrder: this.props.initHighlightedOrder || null });
+ }
+ get highlightedOrder() {
+ return this.state.highlightedOrder;
+ }
+ _onClickOrder({ detail: order }) {
+ this.state.highlightedOrder = order;
+ }
+ }
+ OrderList.template = 'OrderList';
+
+ Registries.Component.add(OrderList);
+
+ return OrderList;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js
new file mode 100644
index 00000000..951a0956
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js
@@ -0,0 +1,124 @@
+odoo.define('point_of_sale.OrderManagementControlPanel', function (require) {
+ 'use strict';
+
+ const { useContext } = owl.hooks;
+ const { useAutofocus, useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const OrderFetcher = require('point_of_sale.OrderFetcher');
+ const contexts = require('point_of_sale.PosContext');
+
+ // NOTE: These are constants so that they are only instantiated once
+ // and they can be used efficiently by the OrderManagementControlPanel.
+ const VALID_SEARCH_TAGS = new Set(['date', 'customer', 'client', 'name', 'order']);
+ const FIELD_MAP = {
+ date: 'date_order',
+ customer: 'partner_id.display_name',
+ client: 'partner_id.display_name',
+ name: 'pos_reference',
+ order: 'pos_reference',
+ };
+ const SEARCH_FIELDS = ['pos_reference', 'partner_id.display_name', 'date_order'];
+
+ function getDomainForSingleCondition(fields, toSearch) {
+ const orSymbols = Array(fields.length - 1).fill('|');
+ return orSymbols.concat(fields.map((field) => [field, 'ilike', `%${toSearch}%`]));
+ }
+
+ /**
+ * @emits close-screen
+ * @emits prev-page
+ * @emits next-page
+ * @emits search
+ */
+ class OrderManagementControlPanel extends PosComponent {
+ constructor() {
+ super(...arguments);
+ // We are using context because we want the `searchString` to be alive
+ // even if this component is destroyed (unmounted).
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ useListener('clear-search', this._onClearSearch);
+ useAutofocus({ selector: 'input' });
+ }
+ onInputKeydown(event) {
+ if (event.key === 'Enter') {
+ this.trigger('search', this._computeDomain());
+ }
+ }
+ get showPageControls() {
+ return OrderFetcher.lastPage > 1;
+ }
+ get pageNumber() {
+ const currentPage = OrderFetcher.currentPage;
+ const lastPage = OrderFetcher.lastPage;
+ return isNaN(lastPage) ? '' : `(${currentPage}/${lastPage})`;
+ }
+ get validSearchTags() {
+ return VALID_SEARCH_TAGS;
+ }
+ get fieldMap() {
+ return FIELD_MAP;
+ }
+ get searchFields() {
+ return SEARCH_FIELDS;
+ }
+ /**
+ * E.g. 1
+ * ```
+ * searchString = 'Customer 1'
+ * result = [
+ * '|',
+ * '|',
+ * ['pos_reference', 'ilike', '%Customer 1%'],
+ * ['partner_id.display_name', 'ilike', '%Customer 1%'],
+ * ['date_order', 'ilike', '%Customer 1%']
+ * ]
+ * ```
+ *
+ * E.g. 2
+ * ```
+ * searchString = 'date: 2020-05'
+ * result = [
+ * ['date_order', 'ilike', '%2020-05%']
+ * ]
+ * ```
+ *
+ * E.g. 3
+ * ```
+ * searchString = 'customer: Steward, date: 2020-05-01'
+ * result = [
+ * ['partner_id.display_name', 'ilike', '%Steward%'],
+ * ['date_order', 'ilike', '%2020-05-01%']
+ * ]
+ * ```
+ */
+ _computeDomain() {
+ const input = this.orderManagementContext.searchString.trim();
+ if (!input) return;
+
+ const searchConditions = this.orderManagementContext.searchString.split(/[,&]\s*/);
+ if (searchConditions.length === 1) {
+ let cond = searchConditions[0].split(/:\s*/);
+ if (cond.length === 1) {
+ return getDomainForSingleCondition(this.searchFields, cond[0]);
+ }
+ }
+ const domain = [];
+ for (let cond of searchConditions) {
+ let [tag, value] = cond.split(/:\s*/);
+ if (!this.validSearchTags.has(tag)) continue;
+ domain.push([this.fieldMap[tag], 'ilike', `%${value}%`]);
+ }
+ return domain;
+ }
+ _onClearSearch() {
+ this.orderManagementContext.searchString = '';
+ this.onInputKeydown({ key: 'Enter' });
+ }
+ }
+ OrderManagementControlPanel.template = 'OrderManagementControlPanel';
+
+ Registries.Component.add(OrderManagementControlPanel);
+
+ return OrderManagementControlPanel;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js
new file mode 100644
index 00000000..dcde9739
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js
@@ -0,0 +1,101 @@
+odoo.define('point_of_sale.OrderManagementScreen', function (require) {
+ 'use strict';
+
+ const { useContext } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const Registries = require('point_of_sale.Registries');
+ const OrderFetcher = require('point_of_sale.OrderFetcher');
+ const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
+ const contexts = require('point_of_sale.PosContext');
+
+ class OrderManagementScreen extends ControlButtonsMixin(IndependentToOrderScreen) {
+ constructor() {
+ super(...arguments);
+ useListener('close-screen', this.close);
+ useListener('set-numpad-mode', this._setNumpadMode);
+ useListener('click-order', this._onClickOrder);
+ useListener('next-page', this._onNextPage);
+ useListener('prev-page', this._onPrevPage);
+ useListener('search', this._onSearch);
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ useWithBarcode: true,
+ });
+ this.numpadMode = 'quantity';
+ OrderFetcher.setComponent(this);
+ OrderFetcher.setConfigId(this.env.pos.config_id);
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ }
+ mounted() {
+ OrderFetcher.on('update', this, this.render);
+ this.env.pos.get('orders').on('add remove', this.render, this);
+
+ // calculate how many can fit in the screen.
+ // It is based on the height of the header element.
+ // So the result is only accurate if each row is just single line.
+ const flexContainer = this.el.querySelector('.flex-container');
+ const cpEl = this.el.querySelector('.control-panel');
+ const headerEl = this.el.querySelector('.order-row.header');
+ const val = Math.trunc(
+ (flexContainer.offsetHeight - cpEl.offsetHeight - headerEl.offsetHeight) /
+ headerEl.offsetHeight
+ );
+ OrderFetcher.setNPerPage(val);
+
+ // Fetch the order after mounting so that order management screen
+ // is shown while fetching.
+ setTimeout(() => OrderFetcher.fetch(), 0);
+ }
+ willUnmount() {
+ OrderFetcher.off('update', this);
+ this.env.pos.get('orders').off('add remove', null, this);
+ }
+ get selectedClient() {
+ const order = this.orderManagementContext.selectedOrder;
+ return order ? order.get_client() : null;
+ }
+ get orders() {
+ return OrderFetcher.get();
+ }
+ async _setNumpadMode(event) {
+ const { mode } = event.detail;
+ this.numpadMode = mode;
+ NumberBuffer.reset();
+ }
+ _onNextPage() {
+ OrderFetcher.nextPage();
+ }
+ _onPrevPage() {
+ OrderFetcher.prevPage();
+ }
+ _onSearch({ detail: domain }) {
+ OrderFetcher.setSearchDomain(domain);
+ OrderFetcher.setPage(1);
+ OrderFetcher.fetch();
+ }
+ _onClickOrder({ detail: clickedOrder }) {
+ if (!clickedOrder || clickedOrder.locked) {
+ this.orderManagementContext.selectedOrder = clickedOrder;
+ } else {
+ this._setOrder(clickedOrder);
+ }
+ }
+ /**
+ * @param {models.Order} order
+ */
+ _setOrder(order) {
+ this.env.pos.set_order(order);
+ if (order === this.env.pos.get_order()) {
+ this.close();
+ }
+ }
+ }
+ OrderManagementScreen.template = 'OrderManagementScreen';
+ OrderManagementScreen.hideOrderSelector = true;
+
+ Registries.Component.add(OrderManagementScreen);
+
+ return OrderManagementScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js
new file mode 100644
index 00000000..959ea5a1
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.OrderRow', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props {models.Order} order
+ * @props columns
+ * @emits click-order
+ */
+ class OrderRow extends PosComponent {
+ get order() {
+ return this.props.order;
+ }
+ get highlighted() {
+ const highlightedOrder = this.props.highlightedOrder;
+ return !highlightedOrder ? false : highlightedOrder.backendId === this.props.order.backendId;
+ }
+
+ // Column getters //
+
+ get name() {
+ return this.order.get_name();
+ }
+ get date() {
+ return moment(this.order.validation_date).format('YYYY-MM-DD hh:mm A');
+ }
+ get customer() {
+ const customer = this.order.get('client');
+ return customer ? customer.name : null;
+ }
+ get total() {
+ return this.env.pos.format_currency(this.order.get_total_with_tax());
+ }
+ }
+ OrderRow.template = 'OrderRow';
+
+ Registries.Component.add(OrderRow);
+
+ return OrderRow;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
new file mode 100644
index 00000000..35f6ec5d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
@@ -0,0 +1,55 @@
+odoo.define('point_of_sale.OrderlineDetails', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { format } = require('web.field_utils');
+ const { round_precision: round_pr } = require('web.utils');
+
+ /**
+ * @props {pos.order.line} line
+ */
+ class OrderlineDetails extends PosComponent {
+ get line() {
+ const line = this.props.line;
+ const formatQty = (line) => {
+ const quantity = line.get_quantity();
+ const unit = line.get_unit();
+ const decimals = this.env.pos.dp['Product Unit of Measure'];
+ const rounding = Math.max(unit.rounding, Math.pow(10, -decimals));
+ const roundedQuantity = round_pr(quantity, rounding);
+ return format.float(roundedQuantity, { digits: [69, decimals] });
+ };
+ return {
+ productName: line.get_full_product_name(),
+ totalPrice: line.get_price_with_tax(),
+ quantity: formatQty(line),
+ unit: line.get_unit().name,
+ unitPrice: line.get_unit_price(),
+ };
+ }
+ get productName() {
+ return this.line.productName;
+ }
+ get totalPrice() {
+ return this.env.pos.format_currency(this.line.totalPrice);
+ }
+ get quantity() {
+ return this.line.quantity;
+ }
+ get unitPrice() {
+ return this.line.unitPrice;
+ }
+ get unit() {
+ return this.line.unit;
+ }
+ get pricePerUnit() {
+ return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`;
+ }
+ }
+ OrderlineDetails.template = 'OrderlineDetails';
+
+ Registries.Component.add(OrderlineDetails);
+
+ return OrderlineDetails;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js
new file mode 100644
index 00000000..7fcc514d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js
@@ -0,0 +1,32 @@
+odoo.define('point_of_sale.ReprintReceiptScreen', function (require) {
+ 'use strict';
+
+ const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
+ const Registries = require('point_of_sale.Registries');
+
+ const ReprintReceiptScreen = (AbstractReceiptScreen) => {
+ class ReprintReceiptScreen extends AbstractReceiptScreen {
+ mounted() {
+ this.printReceipt();
+ }
+ confirm() {
+ this.showScreen('OrderManagementScreen');
+ }
+ async printReceipt() {
+ if(this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen) {
+ let result = await this._printReceipt();
+ if(result)
+ this.showScreen('OrderManagementScreen');
+ }
+ }
+ async tryReprint() {
+ await this._printReceipt();
+ }
+ }
+ ReprintReceiptScreen.template = 'ReprintReceiptScreen';
+ return ReprintReceiptScreen;
+ };
+ Registries.Component.addByExtending(ReprintReceiptScreen, AbstractReceiptScreen);
+
+ return ReprintReceiptScreen;
+});
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 00000000..b5dc6a7b
--- /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 00000000..8e5853d3
--- /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 00000000..6fe25a11
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js
@@ -0,0 +1,376 @@
+odoo.define('point_of_sale.PaymentScreen', function (require) {
+ 'use strict';
+
+ const { parse } = require('web.field_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 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);
+ 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',
+ });
+ onChangeOrder(this._onPrevOrder, this._onNewOrder);
+ useErrorHandlers();
+ this.payment_interface = null;
+ this.error = false;
+ this.payment_methods_from_config = this.env.pos.payment_methods.filter(method => this.env.pos.config.payment_method_ids.includes(method.id));
+ }
+ 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.payment_methods_from_config[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();
+ }
+ 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(this.env.pos.config.cash_rounding) {
+ if(!this.env.pos.get_order().check_paymentlines_rounding()) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Rounding error in payment lines'),
+ body: this.env._t("The amount of your payment lines must be rounded to validate the transaction."),
+ });
+ return;
+ }
+ }
+ 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.currentOrder.get_change()) && this.env.pos.config.iface_cashdrawer) {
+ this.env.pos.proxy.printer.open_cashbox();
+ }
+
+ this.currentOrder.initialize_validation_date();
+ this.currentOrder.finalized = true;
+
+ let syncedOrderBackendIds = [];
+
+ 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.code == 700)
+ this.error = true;
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ 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,
+ });
+ }
+ }
+
+ this.showScreen(this.nextScreen);
+
+ // 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();
+ }
+ }
+ }
+ get nextScreen() {
+ return !this.error? 'ReceiptScreen' : 'ProductScreen';
+ }
+ 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;
+ }
+ }
+
+ // 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) {
+ 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');
+ const isCancelSuccessful = await payment_terminal.send_payment_cancel(this.currentOrder, line.cid);
+ if (isCancelSuccessful) {
+ line.set_payment_status('retry');
+ } else {
+ line.set_payment_status('waitingCard');
+ }
+ }
+ 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 00000000..6cafac15
--- /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 00000000..e661722f
--- /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 00000000..8f231146
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js
@@ -0,0 +1,23 @@
+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());
+ }
+ 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/PaymentScreen/PaymentScreenStatus.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js
new file mode 100644
index 00000000..12ccaa84
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js
@@ -0,0 +1,30 @@
+odoo.define('point_of_sale.PaymentScreenStatus', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenStatus extends PosComponent {
+ 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();
+ }
+ }
+ PaymentScreenStatus.template = 'PaymentScreenStatus';
+
+ Registries.Component.add(PaymentScreenStatus);
+
+ return PaymentScreenStatus;
+});
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 00000000..d30fe85e
--- /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
+ * @emits click-customer
+ * @emits 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/CashBoxOpening.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js
new file mode 100644
index 00000000..be42e45d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.CashBoxOpening', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { Gui } = require('point_of_sale.Gui');
+
+ class CashBoxOpening extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.changes = {};
+ this.defaultValue = this.env.pos.bank_statement.balance_start || 0;
+ this.symbol = this.env.pos.currency.symbol;
+ }
+ captureChange(event) {
+ this.changes[event.target.name] = event.target.value;
+ }
+ startSession() {
+ let cashOpening = this.changes.cashBoxValue? this.changes.cashBoxValue: this.defaultValue;
+ if(isNaN(cashOpening)) {
+ Gui.showPopup('ErrorPopup',{
+ 'title': 'Wrong value',
+ 'body': 'Please insert a correct value.',
+ });
+ return;
+ }
+ this.env.pos.bank_statement.balance_start = cashOpening;
+ this.env.pos.pos_session.state = 'opened';
+ this.props.cashControl.cashControl = false;
+ this.rpc({
+ model: 'pos.session',
+ method: 'set_cashbox_pos',
+ args: [this.env.pos.pos_session.id, cashOpening, this.changes.notes],
+ });
+ }
+ }
+ CashBoxOpening.template = 'CashBoxOpening';
+
+ Registries.Component.add(CashBoxOpening);
+
+ return CashBoxOpening;
+});
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 00000000..843cc248
--- /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 00000000..05914bec
--- /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 00000000..675512d8
--- /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 00000000..901e70e7
--- /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 00000000..c0a01f87
--- /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 00000000..28641236
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js
@@ -0,0 +1,47 @@
+odoo.define('point_of_sale.HomeCategoryBreadcrumb', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+
+ class HomeCategoryBreadcrumb extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('categ-popup', this._categPopup);
+ }
+ get selectedCategoryId() {
+ return this.env.pos.get('selectedCategoryId');
+ }
+ async _categPopup() {
+ let selectionList = [{
+ id: 0,
+ label:'All Items',
+ isSelected: 0 === this.env.pos.get('selectedCategoryId'),
+ item: {id:0,name:'All Items'},
+ }];
+ let subs = this.props.subcategories.map(category => ({
+ id: category.id,
+ label: category.name,
+ isSelected: category.id === this.env.pos.get('selectedCategoryId'),
+ item: category,
+ }));
+ selectionList = selectionList.concat(subs);
+ const { confirmed, payload: selectedCategory } = await this.showPopup(
+ 'SelectionPopup',
+ {
+ title: this.env._t('Select the category'),
+ list: selectionList,
+ }
+ );
+ if (confirmed) {
+ this.trigger('switch-category', selectedCategory.id);
+ }
+ }
+ }
+ 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 00000000..5850dc83
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js
@@ -0,0 +1,59 @@
+odoo.define('point_of_sale.NumpadWidget', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @prop {'quantiy' | 'price' | 'discount'} activeMode
+ * @event set-numpad-mode - triggered when mode button is clicked
+ * @event numpad-click-input - triggered when numpad button is clicked
+ *
+ * IMPROVEMENT: Whenever new-orderline-selected is triggered,
+ * numpad mode should be set to 'quantity'. Now that the mode state
+ * is lifted to the parent component, this improvement can be done in
+ * the parent component.
+ */
+ class NumpadWidget extends PosComponent {
+ mounted() {
+ // IMPROVEMENT: This listener shouldn't be here because in core point_of_sale
+ // there is no way of changing the cashier. Only when pos_hr is installed
+ // that this listener makes sense.
+ this.env.pos.on('change:cashier', () => {
+ if (!this.hasPriceControlRights && this.props.activeMode === 'price') {
+ this.trigger('set-numpad-mode', { 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';
+ }
+ get hasManualDiscount() {
+ return this.env.pos.config.manual_discount;
+ }
+ changeMode(mode) {
+ if (!this.hasPriceControlRights && mode === 'price') {
+ return;
+ }
+ if (!this.hasManualDiscount && mode === 'discount') {
+ return;
+ }
+ this.trigger('set-numpad-mode', { mode });
+ }
+ sendInput(key) {
+ this.trigger('numpad-click-input', { key });
+ }
+ get decimalSeparator() {
+ return this.env._t.database.parameters.decimal_point;
+ }
+ }
+ 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 00000000..aeb9891f
--- /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 00000000..ee610afd
--- /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 00000000..71a96bd4
--- /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 00000000..ac93500c
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
@@ -0,0 +1,49 @@
+odoo.define('point_of_sale.ProductItem', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductItem extends PosComponent {
+ /**
+ * For accessibility, pressing <space> should be like clicking the product.
+ * <enter> is not considered because it conflicts with the barcode.
+ *
+ * @param {KeyPressEvent} event
+ */
+ spaceClickProduct(event) {
+ if (event.which === 32) {
+ this.trigger('click-product', this.props.product);
+ }
+ }
+ get imageUrl() {
+ const product = this.props.product;
+ return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ }
+ get pricelist() {
+ const current_order = this.env.pos.get_order();
+ if (current_order) {
+ return current_order.pricelist;
+ }
+ return this.env.pos.default_pricelist;
+ }
+ get price() {
+ const formattedUnitPrice = this.env.pos.format_currency(
+ this.props.product.get_price(this.pricelist, 1),
+ 'Product Price'
+ );
+ if (this.props.product.to_weight) {
+ return `${formattedUnitPrice}/${
+ this.env.pos.units_by_id[this.props.product.uom_id[0]].name
+ }`;
+ } else {
+ return formattedUnitPrice;
+ }
+ }
+ }
+ ProductItem.template = 'ProductItem';
+
+ Registries.Component.add(ProductItem);
+
+ return ProductItem;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js
new file mode 100644
index 00000000..aeee2ede
--- /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 00000000..65daa7cc
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
@@ -0,0 +1,327 @@
+odoo.define('point_of_sale.ProductScreen', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
+ 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');
+ const { useState } = owl.hooks;
+ const { parse } = require('web.field_utils');
+
+ class ProductScreen extends ControlButtonsMixin(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,
+ });
+ let status = this.showCashBoxOpening()
+ this.state = useState({ cashControl: status, numpadMode: 'quantity' });
+ this.mobile_pane = this.props.mobile_pane || 'right';
+ }
+ 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();
+ }
+ showCashBoxOpening() {
+ if(this.env.pos.config.cash_control && this.env.pos.pos_session.state == 'opening_control')
+ return true;
+ return false;
+ }
+ async _getAddProductOptions(product) {
+ let price_extra = 0.0;
+ let draftPackLotLines, weight, description, packLotLinesToEdit;
+
+ if (this.env.pos.config.product_configurator && _.some(product.attribute_line_ids, (id) => id in this.env.pos.attributes_by_ptal_id)) {
+ let attributes = _.map(product.attribute_line_ids, (id) => this.env.pos.attributes_by_ptal_id[id])
+ .filter((attr) => attr !== undefined);
+ let { confirmed, payload } = await this.showPopup('ProductConfiguratorPopup', {
+ product: product,
+ attributes: attributes,
+ });
+
+ if (confirmed) {
+ description = payload.selected_attributes.join(', ');
+ price_extra += payload.price_extra;
+ } else {
+ return;
+ }
+ }
+
+ // Gather lot information if required.
+ if (['serial', 'lot'].includes(product.tracking) && (this.env.pos.picking_type.use_create_lots || this.env.pos.picking_type.use_existing_lots)) {
+ 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();
+ }
+ }
+
+ return { draftPackLotLines, quantity: weight, description, price_extra };
+ }
+ async _clickProduct(event) {
+ if (!this.currentOrder) {
+ this.env.pos.add_new_order();
+ }
+ const product = event.detail;
+ const options = await this._getAddProductOptions(product);
+ // Do not add product if options is undefined.
+ if (!options) return;
+ // Add the product after having the extra information.
+ this.currentOrder.add_product(product, options);
+ NumberBuffer.reset();
+ }
+ _setNumpadMode(event) {
+ const { mode } = event.detail;
+ NumberBuffer.capture();
+ NumberBuffer.reset();
+ this.state.numpadMode = mode;
+ }
+ async _updateSelectedOrderline(event) {
+ if(this.state.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) {
+ let order = this.env.pos.get_order();
+ let selectedLine = order.get_selected_orderline();
+ let lastId = order.orderlines.last().cid;
+ let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity();
+
+ if(selectedLine.noDecrease) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Invalid action'),
+ body: this.env._t('You are not allowed to change this quantity'),
+ });
+ return;
+ }
+ const parsedInput = event.detail.buffer && parse.float(event.detail.buffer) || 0;
+ if(lastId != selectedLine.cid)
+ this._showDecreaseQuantityPopup();
+ else if(currentQuantity < parsedInput)
+ this._setValue(event.detail.buffer);
+ else if(parsedInput < 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.state.numpadMode === 'quantity') {
+ this.currentOrder.get_selected_orderline().set_quantity(val);
+ } else if (this.state.numpadMode === 'discount') {
+ this.currentOrder.get_selected_orderline().set_discount(val);
+ } else if (this.state.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();
+ }
+ }
+ }
+ async _barcodeProductAction(code) {
+ const product = this.env.pos.db.get_product_by_barcode(code.base_code)
+ if (!product) {
+ return this._barcodeErrorAction(code);
+ }
+ const options = await this._getAddProductOptions(product);
+ // Do not proceed on adding the product when no options is returned.
+ // This is consistent with _clickProduct.
+ if (!options) return;
+
+ // update the options depending on the type of the scanned code
+ if (code.type === 'price') {
+ Object.assign(options, { price: code.value });
+ } else if (code.type === 'weight') {
+ Object.assign(options, {
+ quantity: code.value,
+ merge: false,
+ });
+ } else if (code.type === 'discount') {
+ Object.assign(options, {
+ discount: code.value,
+ merge: false,
+ });
+ }
+ this.currentOrder.add_product(product, options)
+ }
+ _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('Set the new quantity'),
+ });
+ let newQuantity = inputNumber !== "" ? parse.float(inputNumber) : null;
+ if (confirmed && newQuantity !== null) {
+ let order = this.env.pos.get_order();
+ let selectedLine = this.env.pos.get_order().get_selected_orderline();
+ let currentQuantity = selectedLine.get_quantity()
+ if(selectedLine.is_last_line() && currentQuantity === 1 && newQuantity < currentQuantity)
+ selectedLine.set_quantity(newQuantity);
+ else if(newQuantity >= currentQuantity)
+ selectedLine.set_quantity(newQuantity);
+ else {
+ let newLine = selectedLine.clone();
+ let decreasedQuantity = currentQuantity - newQuantity
+ newLine.order = order;
+
+ newLine.set_quantity( - decreasedQuantity, true);
+ order.add_orderline(newLine);
+ }
+ }
+ }
+ 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');
+ }
+ switchPane() {
+ if (this.mobile_pane === "left") {
+ this.mobile_pane = "right";
+ this.render();
+ }
+ else {
+ this.mobile_pane = "left";
+ this.render();
+ }
+ }
+ }
+ ProductScreen.template = 'ProductScreen';
+
+ 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 00000000..17481058
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js
@@ -0,0 +1,88 @@
+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('try-add-product', this._tryAddProduct);
+ 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));
+ }
+ get hasNoCategories() {
+ return this.env.pos.db.get_category_childs_ids(0).length === 0;
+ }
+ _switchCategory(event) {
+ this.env.pos.set('selectedCategoryId', event.detail);
+ }
+ _updateSearch(event) {
+ this.state.searchWord = event.detail;
+ }
+ _tryAddProduct(event) {
+ const searchResults = this.productsToDisplay;
+ // If the search result contains one item, add the product and clear the search.
+ if (searchResults.length === 1) {
+ const { searchWordInput } = event.detail;
+ this.trigger('click-product', searchResults[0]);
+ // the value of the input element is not linked to the searchWord state,
+ // so we clear both the state and the element's value.
+ searchWordInput.el.value = '';
+ this._clearSearch();
+ }
+ }
+ _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 00000000..fc2df5b0
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js
@@ -0,0 +1,33 @@
+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.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);
+ if (event.key === 'Enter') {
+ // We are passing the searchWordInput ref so that when necessary,
+ // it can be modified by the parent.
+ this.trigger('try-add-product', { searchWordInput: this.searchWordInput });
+ }
+ }
+ }
+ 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 00000000..c06b6339
--- /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.is_in_unit &&
+ 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 00000000..720c65e4
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
@@ -0,0 +1,123 @@
+odoo.define('point_of_sale.ReceiptScreen', function (require) {
+ 'use strict';
+
+ const { Printer } = require('point_of_sale.Printer');
+ const { is_email } = require('web.utils');
+ const { useRef, useContext } = owl.hooks;
+ const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+ const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
+
+ const ReceiptScreen = (AbstractReceiptScreen) => {
+ class ReceiptScreen extends AbstractReceiptScreen {
+ constructor() {
+ super(...arguments);
+ useErrorHandlers();
+ onChangeOrder(null, (newOrder) => newOrder && this.render());
+ this.orderReceipt = useRef('order-receipt');
+ const order = this.currentOrder;
+ const client = order.get_client();
+ this.orderUiState = useContext(order.uiState.ReceiptScreen);
+ this.orderUiState.inputEmail = this.orderUiState.inputEmail || (client && client.email) || '';
+ this.is_email = is_email;
+ }
+ 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);
+ }
+ async onSendEmail() {
+ if (!is_email(this.orderUiState.inputEmail)) {
+ this.orderUiState.emailSuccessful = false;
+ this.orderUiState.emailNotice = this.env._t('Invalid email.');
+ return;
+ }
+ try {
+ await this._sendReceiptToCustomer();
+ this.orderUiState.emailSuccessful = true;
+ this.orderUiState.emailNotice = this.env._t('Email sent.');
+ } catch (error) {
+ this.orderUiState.emailSuccessful = false;
+ this.orderUiState.emailNotice = this.env._t('Sending email failed. Please try again.');
+ }
+ }
+ get orderAmountPlusTip() {
+ const order = this.currentOrder;
+ const orderTotalAmount = order.get_total_with_tax();
+ const tip_product_id = this.env.pos.config.tip_product_id && this.env.pos.config.tip_product_id[0];
+ const tipLine = order
+ .get_orderlines()
+ .find((line) => tip_product_id && line.product.id === tip_product_id);
+ const tipAmount = tipLine ? tipLine.get_all_prices().priceWithTax : 0;
+ const orderAmountStr = this.env.pos.format_currency(orderTotalAmount - tipAmount);
+ if (!tipAmount) return orderAmountStr;
+ const tipAmountStr = this.env.pos.format_currency(tipAmount);
+ return `${orderAmountStr} + ${tipAmountStr} tip`;
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get nextScreen() {
+ return { name: 'ProductScreen' };
+ }
+ whenClosing() {
+ this.orderDone();
+ }
+ /**
+ * 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()) {
+ await this.printReceipt();
+ if (this.currentOrder._printed && this._shouldCloseImmediately()) {
+ this.whenClosing();
+ }
+ }
+ }
+ orderDone() {
+ this.currentOrder.finalize();
+ const { name, props } = this.nextScreen;
+ this.showScreen(name, props);
+ }
+ async printReceipt() {
+ const isPrinted = await this._printReceipt();
+ if (isPrinted) {
+ this.currentOrder._printed = true;
+ }
+ }
+ _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 _sendReceiptToCustomer() {
+ const printer = new Printer();
+ const receiptString = this.orderReceipt.comp.el.outerHTML;
+ const ticketImage = await printer.htmlToImg(receiptString);
+ const order = this.currentOrder;
+ const client = order.get_client();
+ const orderName = order.get_name();
+ const orderClient = { email: this.orderUiState.inputEmail, name: client ? client.name : this.orderUiState.inputEmail };
+ const order_server_id = this.env.pos.validated_orders_name_server_id_map[orderName];
+ await this.rpc({
+ model: 'pos.order',
+ method: 'action_receipt_to_customer',
+ args: [[order_server_id], orderName, orderClient, ticketImage],
+ });
+ }
+ }
+ ReceiptScreen.template = 'ReceiptScreen';
+ return ReceiptScreen;
+ };
+
+ Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen);
+
+ 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 00000000..e7527eee
--- /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 00000000..f9b1ea97
--- /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/Screens/TicketScreen/TicketScreen.js b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
new file mode 100644
index 00000000..f59b72d0
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
@@ -0,0 +1,220 @@
+odoo.define('point_of_sale.TicketScreen', function (require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+ const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
+ const { useListener } = require('web.custom_hooks');
+ const { posbus } = require('point_of_sale.utils');
+
+ class TicketScreen extends IndependentToOrderScreen {
+ constructor() {
+ super(...arguments);
+ useListener('close-screen', this.close);
+ useListener('filter-selected', this._onFilterSelected);
+ useListener('search', this._onSearch);
+ this.searchDetails = {};
+ this.filter = null;
+ this._initializeSearchFieldConstants();
+ }
+ mounted() {
+ posbus.on('ticket-button-clicked', this, this.close);
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ posbus.off('ticket-button-clicked', this);
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ _onFilterSelected(event) {
+ this.filter = event.detail.filter;
+ this.render();
+ }
+ _onSearch(event) {
+ const searchDetails = event.detail;
+ Object.assign(this.searchDetails, searchDetails);
+ this.render();
+ }
+ /**
+ * Override to conditionally show the new ticket button.
+ */
+ get showNewTicketButton() {
+ return true;
+ }
+ get orderList() {
+ return this.env.pos.get_order_list();
+ }
+ get filteredOrderList() {
+ const { AllTickets } = this.getOrderStates();
+ const filterCheck = (order) => {
+ if (this.filter && this.filter !== AllTickets) {
+ const screen = order.get_screen_data();
+ return this.filter === this.constants.screenToStatusMap[screen.name];
+ }
+ return true;
+ };
+ const { fieldValue, searchTerm } = this.searchDetails;
+ const fieldAccessor = this._searchFields[fieldValue];
+ const searchCheck = (order) => {
+ if (!fieldAccessor) return true;
+ const fieldValue = fieldAccessor(order);
+ if (fieldValue === null) return true;
+ if (!searchTerm) return true;
+ return fieldValue && fieldValue.toString().toLowerCase().includes(searchTerm.toLowerCase());
+ };
+ const predicate = (order) => {
+ return filterCheck(order) && searchCheck(order);
+ };
+ return this.orderList.filter(predicate);
+ }
+ selectOrder(order) {
+ this._setOrder(order);
+ if (order === this.env.pos.get_order()) {
+ this.close();
+ }
+ }
+ _setOrder(order) {
+ this.env.pos.set_order(order);
+ }
+ createNewOrder() {
+ this.env.pos.add_new_order();
+ }
+ async deleteOrder(order) {
+ const screen = order.get_screen_data();
+ if (['ProductScreen', 'PaymentScreen'].includes(screen.name) && order.get_orderlines().length > 0) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: 'Existing orderlines',
+ body: `${order.name} has total amount of ${this.getTotal(
+ order
+ )}, are you sure you want delete this order?`,
+ });
+ if (!confirmed) return;
+ }
+ if (order) {
+ await this._canDeleteOrder(order);
+ order.destroy({ reason: 'abandon' });
+ }
+ posbus.trigger('order-deleted');
+ }
+ getDate(order) {
+ return moment(order.creation_date).format('YYYY-MM-DD hh:mm A');
+ }
+ getTotal(order) {
+ return this.env.pos.format_currency(order.get_total_with_tax());
+ }
+ getCustomer(order) {
+ return order.get_client_name();
+ }
+ getCardholderName(order) {
+ return order.get_cardholder_name();
+ }
+ getEmployee(order) {
+ return order.employee ? order.employee.name : '';
+ }
+ getStatus(order) {
+ const screen = order.get_screen_data();
+ return this.constants.screenToStatusMap[screen.name];
+ }
+ /**
+ * Hide the delete button if one of the payments is a 'done' electronic payment.
+ */
+ hideDeleteButton(order) {
+ return order
+ .get_paymentlines()
+ .some((payment) => payment.is_electronic() && payment.get_payment_status() === 'done');
+ }
+ showCardholderName() {
+ return this.env.pos.payment_methods.some(method => method.use_payment_terminal);
+ }
+ get searchBarConfig() {
+ return {
+ searchFields: this.constants.searchFieldNames,
+ filter: { show: true, options: this.filterOptions },
+ };
+ }
+ get filterOptions() {
+ const { AllTickets, Ongoing, Payment, Receipt } = this.getOrderStates();
+ return [AllTickets, Ongoing, Payment, Receipt];
+ }
+ /**
+ * An object with keys containing the search field names which map to functions.
+ * The mapped functions will be used to generate representative string for the order
+ * to match the search term when searching.
+ * E.g. Given 2 orders, search those with `Receipt Number` containing `1111`.
+ * ```
+ * orders = [{
+ * name: '000-1111-222'
+ * total: 10,
+ * }, {
+ * name: '444-5555-666'
+ * total: 15,
+ * }]
+ * ```
+ * `Receipt Number` search field maps to the `name` of the order. So, the orders will be
+ * represented by their name, and the search will result to:
+ * ```
+ * result = [{
+ * name: '000-1111-222',
+ * total: 10,
+ * }]
+ * ```
+ * @returns Record<string, (models.Order) => string>
+ */
+ get _searchFields() {
+ const { ReceiptNumber, Date, Customer, CardholderName } = this.getSearchFieldNames();
+ var fields = {
+ [ReceiptNumber]: (order) => order.name,
+ [Date]: (order) => moment(order.creation_date).format('YYYY-MM-DD hh:mm A'),
+ [Customer]: (order) => order.get_client_name(),
+ };
+
+ if (this.showCardholderName()) {
+ fields[CardholderName] = (order) => order.get_cardholder_name();
+ }
+
+ return fields;
+ }
+ /**
+ * Maps the order screen params to order status.
+ */
+ get _screenToStatusMap() {
+ const { Ongoing, Payment, Receipt } = this.getOrderStates();
+ return {
+ ProductScreen: Ongoing,
+ PaymentScreen: Payment,
+ ReceiptScreen: Receipt,
+ };
+ }
+ _initializeSearchFieldConstants() {
+ this.constants = {};
+ Object.assign(this.constants, {
+ searchFieldNames: Object.keys(this._searchFields),
+ screenToStatusMap: this._screenToStatusMap,
+ });
+ }
+ async _canDeleteOrder(order) {
+ return true;
+ }
+ getOrderStates() {
+ return {
+ AllTickets: this.env._t('All Tickets'),
+ Ongoing: this.env._t('Ongoing'),
+ Payment: this.env._t('Payment'),
+ Receipt: this.env._t('Receipt'),
+ };
+ }
+ getSearchFieldNames() {
+ return {
+ ReceiptNumber: this.env._t('Receipt Number'),
+ Date: this.env._t('Date'),
+ Customer: this.env._t('Customer'),
+ CardholderName: this.env._t('Cardholder Name'),
+ };
+ }
+ }
+ TicketScreen.template = 'TicketScreen';
+
+ Registries.Component.add(TicketScreen);
+
+ return TicketScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/barcode_reader.js b/addons/point_of_sale/static/src/js/barcode_reader.js
new file mode 100644
index 00000000..10f5b9a2
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/barcode_reader.js
@@ -0,0 +1,158 @@
+odoo.define('point_of_sale.BarcodeReader', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+// this module interfaces with the barcode reader. It assumes the barcode reader
+// is set-up to act like a keyboard. Use connect() and disconnect() to activate
+// and deactivate the barcode reader. Use set_action_callbacks to tell it
+// what to do when it reads a barcode.
+var BarcodeReader = core.Class.extend({
+ actions:[
+ 'product',
+ 'cashier',
+ 'client',
+ ],
+
+ init: function (attributes) {
+ this.pos = attributes.pos;
+ this.action_callbacks = {};
+ this.exclusive_callbacks = {};
+ this.proxy = attributes.proxy;
+ this.remote_scanning = false;
+ this.remote_active = 0;
+
+ this.barcode_parser = attributes.barcode_parser;
+
+ this.action_callback_stack = [];
+
+ core.bus.on('barcode_scanned', this, function (barcode) {
+ this.scan(barcode);
+ });
+ },
+
+ set_barcode_parser: function (barcode_parser) {
+ this.barcode_parser = barcode_parser;
+ },
+
+ // 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))
+ //
+ // callbacks is a Map of 'actions' : callback(parsed_barcode)
+ // that sets the callback for each action. if a callback for the
+ // specified action already exists, it is replaced.
+ //
+ // possible actions include :
+ // 'product' | 'cashier' | 'client' | 'discount'
+ set_action_callback: function (name, callback) {
+ if (this.action_callbacks[name]) {
+ this.action_callbacks[name].add(callback);
+ } else {
+ 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];
+ }
+ }
+ },
+
+ /**
+ * 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;
+
+ 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);
+ }
+
+ },
+
+ // the barcode scanner will listen on the hw_proxy/scanner interface for
+ // scan events until disconnect_from_proxy is called
+ connect_to_proxy: function () {
+ var self = this;
+ this.remote_scanning = true;
+ if (this.remote_active >= 1) {
+ return;
+ }
+ this.remote_active = 1;
+
+ function waitforbarcode(){
+ return self.proxy.connection.rpc('/hw_proxy/scanner',{},{shadow: true, timeout:7500})
+ .then(function (barcode) {
+ if (!self.remote_scanning) {
+ self.remote_active = 0;
+ return;
+ }
+ self.scan(barcode);
+ waitforbarcode();
+ },
+ function () {
+ if (!self.remote_scanning) {
+ self.remote_active = 0;
+ return;
+ }
+ waitforbarcode();
+ });
+ }
+ waitforbarcode();
+ },
+
+ // the barcode scanner will stop listening on the hw_proxy/scanner remote interface
+ disconnect_from_proxy: function () {
+ this.remote_scanning = false;
+ },
+});
+
+return BarcodeReader;
+
+});
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 00000000..c1e87b24
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/custom_hooks.js
@@ -0,0 +1,149 @@
+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 if (error.code === 700) {
+ // Fiscal module errors
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Fiscal data module error'),
+ body:
+ error.data.error.status ||
+ this.env._t('The fiscal data module 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
new file mode 100644
index 00000000..ca0afd28
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/db.js
@@ -0,0 +1,556 @@
+odoo.define('point_of_sale.DB', function (require) {
+"use strict";
+
+var core = require('web.core');
+var utils = require('web.utils');
+/* The PosDB holds reference to data that is either
+ * - static: does not change between pos reloads
+ * - persistent : must stay between reloads ( orders )
+ */
+
+var PosDB = core.Class.extend({
+ name: 'openerp_pos_db', //the prefix of the localstorage data
+ limit: 100, // the maximum number of results returned by a search
+ init: function(options){
+ options = options || {};
+ this.name = options.name || this.name;
+ this.limit = options.limit || this.limit;
+
+ if (options.uuid) {
+ this.name = this.name + '_' + options.uuid;
+ }
+
+ //cache the data in memory to avoid roundtrips to the localstorage
+ this.cache = {};
+
+ this.product_by_id = {};
+ this.product_by_barcode = {};
+ this.product_by_category_id = {};
+
+ this.partner_sorted = [];
+ this.partner_by_id = {};
+ this.partner_by_barcode = {};
+ this.partner_search_string = "";
+ this.partner_write_date = null;
+
+ this.category_by_id = {};
+ this.root_category_id = 0;
+ this.category_products = {};
+ this.category_ancestors = {};
+ this.category_childs = {};
+ this.category_parent = {};
+ this.category_search_string = {};
+ },
+
+ /**
+ * sets an uuid to prevent conflict in locally stored data between multiple PoS Configs. By
+ * using the uuid of the config the local storage from other configs will not get effected nor
+ * loaded in sessions that don't belong to them.
+ *
+ * @param {string} uuid Unique identifier of the PoS Config linked to the current session.
+ */
+ set_uuid: function(uuid){
+ this.name = this.name + '_' + uuid;
+ },
+
+ /* returns the category object from its id. If you pass a list of id as parameters, you get
+ * a list of category objects.
+ */
+ get_category_by_id: function(categ_id){
+ if(categ_id instanceof Array){
+ var list = [];
+ for(var i = 0, len = categ_id.length; i < len; i++){
+ var cat = this.category_by_id[categ_id[i]];
+ if(cat){
+ list.push(cat);
+ }else{
+ console.error("get_category_by_id: no category has id:",categ_id[i]);
+ }
+ }
+ return list;
+ }else{
+ return this.category_by_id[categ_id];
+ }
+ },
+ /* returns a list of the category's child categories ids, or an empty list
+ * if a category has no childs */
+ get_category_childs_ids: function(categ_id){
+ return this.category_childs[categ_id] || [];
+ },
+ /* returns a list of all ancestors (parent, grand-parent, etc) categories ids
+ * starting from the root category to the direct parent */
+ get_category_ancestors_ids: function(categ_id){
+ return this.category_ancestors[categ_id] || [];
+ },
+ /* returns the parent category's id of a category, or the root_category_id if no parent.
+ * the root category is parent of itself. */
+ get_category_parent_id: function(categ_id){
+ return this.category_parent[categ_id] || this.root_category_id;
+ },
+ /* adds categories definitions to the database. categories is a list of categories objects as
+ * returned by the openerp server. Categories must be inserted before the products or the
+ * product/ categories association may (will) not work properly */
+ add_categories: function(categories){
+ var self = this;
+ if(!this.category_by_id[this.root_category_id]){
+ this.category_by_id[this.root_category_id] = {
+ id : this.root_category_id,
+ name : 'Root',
+ };
+ }
+ categories.forEach(function(cat){
+ self.category_by_id[cat.id] = cat;
+ });
+ categories.forEach(function(cat){
+ var parent_id = cat.parent_id[0];
+ if(!(parent_id && self.category_by_id[parent_id])){
+ parent_id = self.root_category_id;
+ }
+ self.category_parent[cat.id] = parent_id;
+ if(!self.category_childs[parent_id]){
+ self.category_childs[parent_id] = [];
+ }
+ self.category_childs[parent_id].push(cat.id);
+ });
+ function make_ancestors(cat_id, ancestors){
+ self.category_ancestors[cat_id] = ancestors;
+
+ ancestors = ancestors.slice(0);
+ ancestors.push(cat_id);
+
+ var childs = self.category_childs[cat_id] || [];
+ for(var i=0, len = childs.length; i < len; i++){
+ make_ancestors(childs[i], ancestors);
+ }
+ }
+ make_ancestors(this.root_category_id, []);
+ },
+ category_contains: function(categ_id, product_id) {
+ var product = this.product_by_id[product_id];
+ if (product) {
+ var cid = product.pos_categ_id[0];
+ while (cid && cid !== categ_id){
+ cid = this.category_parent[cid];
+ }
+ return !!cid;
+ }
+ return false;
+ },
+ /* loads a record store from the database. returns default if nothing is found */
+ load: function(store,deft){
+ if(this.cache[store] !== undefined){
+ return this.cache[store];
+ }
+ var data = localStorage[this.name + '_' + store];
+ if(data !== undefined && data !== ""){
+ data = JSON.parse(data);
+ this.cache[store] = data;
+ return data;
+ }else{
+ return deft;
+ }
+ },
+ /* saves a record store to the database */
+ save: function(store,data){
+ localStorage[this.name + '_' + store] = JSON.stringify(data);
+ this.cache[store] = data;
+ },
+ _product_search_string: function(product){
+ var str = product.display_name;
+ if (product.barcode) {
+ str += '|' + product.barcode;
+ }
+ if (product.default_code) {
+ str += '|' + product.default_code;
+ }
+ if (product.description) {
+ str += '|' + product.description;
+ }
+ if (product.description_sale) {
+ str += '|' + product.description_sale;
+ }
+ str = product.id + ':' + str.replace(/:/g,'') + '\n';
+ return str;
+ },
+ add_products: function(products){
+ var stored_categories = this.product_by_category_id;
+
+ if(!products instanceof Array){
+ products = [products];
+ }
+ for(var i = 0, len = products.length; i < len; i++){
+ var product = products[i];
+ if (product.id in this.product_by_id) continue;
+ if (product.available_in_pos){
+ 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];
+ if(!stored_categories[categ_id]){
+ stored_categories[categ_id] = [];
+ }
+ stored_categories[categ_id].push(product.id);
+
+ if(this.category_search_string[categ_id] === undefined){
+ this.category_search_string[categ_id] = '';
+ }
+ this.category_search_string[categ_id] += search_string;
+
+ var ancestors = this.get_category_ancestors_ids(categ_id) || [];
+
+ for(var j = 0, jlen = ancestors.length; j < jlen; j++){
+ var ancestor = ancestors[j];
+ if(! stored_categories[ancestor]){
+ stored_categories[ancestor] = [];
+ }
+ stored_categories[ancestor].push(product.id);
+
+ if( this.category_search_string[ancestor] === undefined){
+ this.category_search_string[ancestor] = '';
+ }
+ this.category_search_string[ancestor] += search_string;
+ }
+ }
+ this.product_by_id[product.id] = product;
+ if(product.barcode){
+ this.product_by_barcode[product.barcode] = product;
+ }
+ }
+ },
+ _partner_search_string: function(partner){
+ var str = partner.name || '';
+ if(partner.barcode){
+ str += '|' + partner.barcode;
+ }
+ if(partner.address){
+ str += '|' + partner.address;
+ }
+ if(partner.phone){
+ str += '|' + partner.phone.split(' ').join('');
+ }
+ if(partner.mobile){
+ str += '|' + partner.mobile.split(' ').join('');
+ }
+ if(partner.email){
+ str += '|' + partner.email;
+ }
+ if(partner.vat){
+ str += '|' + partner.vat;
+ }
+ str = '' + partner.id + ':' + str.replace(':', '').replace(/\n/g, ' ') + '\n';
+ return str;
+ },
+ add_partners: function(partners){
+ var updated_count = 0;
+ var new_write_date = '';
+ var partner;
+ for(var i = 0, len = partners.length; i < len; i++){
+ partner = partners[i];
+
+ var local_partner_date = (this.partner_write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z');
+ var dist_partner_date = (partner.write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z');
+ if ( this.partner_write_date &&
+ this.partner_by_id[partner.id] &&
+ new Date(local_partner_date).getTime() + 1000 >=
+ new Date(dist_partner_date).getTime() ) {
+ // FIXME: The write_date is stored with milisec precision in the database
+ // but the dates we get back are only precise to the second. This means when
+ // you read partners modified strictly after time X, you get back partners that were
+ // modified X - 1 sec ago.
+ continue;
+ } else if ( new_write_date < partner.write_date ) {
+ new_write_date = partner.write_date;
+ }
+ if (!this.partner_by_id[partner.id]) {
+ this.partner_sorted.push(partner.id);
+ }
+ this.partner_by_id[partner.id] = partner;
+
+ updated_count += 1;
+ }
+
+ this.partner_write_date = new_write_date || this.partner_write_date;
+
+ if (updated_count) {
+ // If there were updates, we need to completely
+ // rebuild the search string and the barcode indexing
+
+ this.partner_search_string = "";
+ this.partner_by_barcode = {};
+
+ for (var id in this.partner_by_id) {
+ partner = this.partner_by_id[id];
+
+ if(partner.barcode){
+ this.partner_by_barcode[partner.barcode] = partner;
+ }
+ partner.address = (partner.street ? partner.street + ', ': '') +
+ (partner.zip ? partner.zip + ', ': '') +
+ (partner.city ? partner.city + ', ': '') +
+ (partner.state_id ? partner.state_id[1] + ', ': '') +
+ (partner.country_id ? partner.country_id[1]: '');
+ this.partner_search_string += this._partner_search_string(partner);
+ }
+
+ this.partner_search_string = utils.unaccent(this.partner_search_string);
+ }
+ return updated_count;
+ },
+ get_partner_write_date: function(){
+ return this.partner_write_date || "1970-01-01 00:00:00";
+ },
+ get_partner_by_id: function(id){
+ return this.partner_by_id[id];
+ },
+ get_partner_by_barcode: function(barcode){
+ return this.partner_by_barcode[barcode];
+ },
+ get_partners_sorted: function(max_count){
+ max_count = max_count ? Math.min(this.partner_sorted.length, max_count) : this.partner_sorted.length;
+ var partners = [];
+ for (var i = 0; i < max_count; i++) {
+ partners.push(this.partner_by_id[this.partner_sorted[i]]);
+ }
+ return partners;
+ },
+ search_partner: function(query){
+ try {
+ query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.');
+ query = query.replace(/ /g,'.+');
+ var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi");
+ }catch(e){
+ return [];
+ }
+ var results = [];
+ for(var i = 0; i < this.limit; i++){
+ var r = re.exec(this.partner_search_string);
+ if(r){
+ var id = Number(r[1]);
+ results.push(this.get_partner_by_id(id));
+ }else{
+ break;
+ }
+ }
+ return results;
+ },
+ /* removes all the data from the database. TODO : being able to selectively remove data */
+ clear: function(){
+ for(var i = 0, len = arguments.length; i < len; i++){
+ localStorage.removeItem(this.name + '_' + arguments[i]);
+ }
+ },
+ /* this internal methods returns the count of properties in an object. */
+ _count_props : function(obj){
+ var count = 0;
+ for(var prop in obj){
+ if(obj.hasOwnProperty(prop)){
+ count++;
+ }
+ }
+ return count;
+ },
+ get_product_by_id: function(id){
+ return this.product_by_id[id];
+ },
+ get_product_by_barcode: function(barcode){
+ if(this.product_by_barcode[barcode]){
+ return this.product_by_barcode[barcode];
+ } else {
+ return undefined;
+ }
+ },
+ get_product_by_category: function(category_id){
+ var product_ids = this.product_by_category_id[category_id];
+ var list = [];
+ if (product_ids) {
+ for (var i = 0, len = Math.min(product_ids.length, this.limit); i < len; i++) {
+ list.push(this.product_by_id[product_ids[i]]);
+ }
+ }
+ return list;
+ },
+ /* returns a list of products with :
+ * - a category that is or is a child of category_id,
+ * - a name, package or barcode containing the query (case insensitive)
+ */
+ search_product_in_category: function(category_id, query){
+ try {
+ query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.');
+ query = query.replace(/ /g,'.+');
+ var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi");
+ }catch(e){
+ return [];
+ }
+ var results = [];
+ for(var i = 0; i < this.limit; i++){
+ var r = re.exec(this.category_search_string[category_id]);
+ if(r){
+ var id = Number(r[1]);
+ results.push(this.get_product_by_id(id));
+ }else{
+ break;
+ }
+ }
+ return results;
+ },
+ /* from a product id, and a list of category ids, returns
+ * true if the product belongs to one of the provided category
+ * or one of its child categories.
+ */
+ is_product_in_category: function(category_ids, product_id) {
+ if (!(category_ids instanceof Array)) {
+ category_ids = [category_ids];
+ }
+ var cat = this.get_product_by_id(product_id).pos_categ_id[0];
+ while (cat) {
+ for (var i = 0; i < category_ids.length; i++) {
+ if (cat == category_ids[i]) { // The == is important, ids may be strings
+ return true;
+ }
+ }
+ cat = this.get_category_parent_id(cat);
+ }
+ return false;
+ },
+
+ /* paid orders */
+ add_order: function(order){
+ var order_id = order.uid;
+ var orders = this.load('orders',[]);
+
+ // if the order was already stored, we overwrite its data
+ for(var i = 0, len = orders.length; i < len; i++){
+ if(orders[i].id === order_id){
+ orders[i].data = order;
+ this.save('orders',orders);
+ return order_id;
+ }
+ }
+
+ // Only necessary when we store a new, validated order. Orders
+ // that where already stored should already have been removed.
+ this.remove_unpaid_order(order);
+
+ orders.push({id: order_id, data: order});
+ this.save('orders',orders);
+ return order_id;
+ },
+ remove_order: function(order_id){
+ var orders = this.load('orders',[]);
+ orders = _.filter(orders, function(order){
+ return order.id !== order_id;
+ });
+ this.save('orders',orders);
+ },
+ remove_all_orders: function(){
+ this.save('orders',[]);
+ },
+ get_orders: function(){
+ return this.load('orders',[]);
+ },
+ get_order: function(order_id){
+ var orders = this.get_orders();
+ for(var i = 0, len = orders.length; i < len; i++){
+ if(orders[i].id === order_id){
+ return orders[i];
+ }
+ }
+ return undefined;
+ },
+
+ /* working orders */
+ save_unpaid_order: function(order){
+ var order_id = order.uid;
+ var orders = this.load('unpaid_orders',[]);
+ var serialized = order.export_as_JSON();
+
+ for (var i = 0; i < orders.length; i++) {
+ if (orders[i].id === order_id){
+ orders[i].data = serialized;
+ this.save('unpaid_orders',orders);
+ return order_id;
+ }
+ }
+
+ orders.push({id: order_id, data: serialized});
+ this.save('unpaid_orders',orders);
+ return order_id;
+ },
+ remove_unpaid_order: function(order){
+ var orders = this.load('unpaid_orders',[]);
+ orders = _.filter(orders, function(o){
+ return o.id !== order.uid;
+ });
+ this.save('unpaid_orders',orders);
+ },
+ remove_all_unpaid_orders: function(){
+ this.save('unpaid_orders',[]);
+ },
+ get_unpaid_orders: function(){
+ var saved = this.load('unpaid_orders',[]);
+ var orders = [];
+ for (var i = 0; i < saved.length; i++) {
+ orders.push(saved[i].data);
+ }
+ return orders;
+ },
+ /**
+ * Return the orders with requested ids if they are unpaid.
+ * @param {array<number>} ids order_ids.
+ * @return {array<object>} list of orders.
+ */
+ get_unpaid_orders_to_sync: function(ids){
+ var saved = this.load('unpaid_orders',[]);
+ var orders = [];
+ saved.forEach(function(o) {
+ if (ids.includes(o.id) && (o.data.server_id || o.data.lines.length)){
+ orders.push(o);
+ }
+ });
+ return orders;
+ },
+ /**
+ * Add a given order to the orders to be removed from the server.
+ *
+ * If an order is removed from a table it also has to be removed from the server to prevent it from reapearing
+ * after syncing. This function will add the server_id of the order to a list of orders still to be removed.
+ * @param {object} order object.
+ */
+ set_order_to_remove_from_server: function(order){
+ if (order.server_id !== undefined) {
+ var to_remove = this.load('unpaid_orders_to_remove',[]);
+ to_remove.push(order.server_id);
+ this.save('unpaid_orders_to_remove', to_remove);
+ }
+ },
+ /**
+ * Get a list of server_ids of orders to be removed.
+ * @return {array<number>} list of server_ids.
+ */
+ get_ids_to_remove_from_server: function(){
+ return this.load('unpaid_orders_to_remove',[]);
+ },
+ /**
+ * Remove server_ids from the list of orders to be removed.
+ * @param {array<number>} ids
+ */
+ set_ids_removed_from_server: function(ids){
+ var to_remove = this.load('unpaid_orders_to_remove',[]);
+
+ to_remove = _.filter(to_remove, function(id){
+ return !ids.includes(id);
+ });
+ this.save('unpaid_orders_to_remove', to_remove);
+ },
+ set_cashier: function(cashier) {
+ // Always update if the user is the same as before
+ this.save('cashier', cashier || null);
+ },
+ get_cashier: function() {
+ return this.load('cashier');
+ }
+});
+
+return PosDB;
+
+});
+
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 00000000..bcc9f608
--- /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/ui/tests?mod=*',
+ });
+ },
+ });
+});
diff --git a/addons/point_of_sale/static/src/js/devices.js b/addons/point_of_sale/static/src/js/devices.js
new file mode 100644
index 00000000..a4a80a9c
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/devices.js
@@ -0,0 +1,492 @@
+odoo.define('point_of_sale.devices', function (require) {
+"use strict";
+
+var core = require('web.core');
+var mixins = require('web.mixins');
+var Session = require('web.Session');
+var Printer = require('point_of_sale.Printer').Printer;
+
+// the JobQueue schedules a sequence of 'jobs'. each job is
+// a function returning a promise. The queue waits for each job to finish
+// before launching the next. Each job can also be scheduled with a delay.
+// the is used to prevent parallel requests to the proxy.
+
+var JobQueue = function(){
+ var queue = [];
+ var running = false;
+ var scheduled_end_time = 0;
+ var end_of_queue = Promise.resolve();
+ var stoprepeat = false;
+
+ var run = function () {
+ var runNextJob = function () {
+ if (queue.length === 0) {
+ running = false;
+ scheduled_end_time = 0;
+ return Promise.resolve();
+ }
+ running = true;
+ var job = queue[0];
+ if (!job.opts.repeat || stoprepeat) {
+ queue.shift();
+ stoprepeat = false;
+ }
+
+ // the time scheduled for this job
+ scheduled_end_time = (new Date()).getTime() + (job.opts.duration || 0);
+
+ // we run the job and put in prom when it finishes
+ var prom = job.fun() || Promise.resolve();
+
+ var always = function () {
+ // we run the next job after the scheduled_end_time, even if it finishes before
+ return new Promise(function (resolve, reject) {
+ setTimeout(
+ resolve,
+ Math.max(0, scheduled_end_time - (new Date()).getTime())
+ );
+ });
+ };
+ // we don't care if a job fails ...
+ return prom.then(always, always).then(runNextJob);
+ };
+
+ if (!running) {
+ end_of_queue = runNextJob();
+ }
+ };
+
+ /**
+ * Adds a job to the schedule.
+ *
+ * @param {function} fun must return a promise
+ * @param {object} [opts]
+ * @param {number} [opts.duration] the job is guaranteed to finish no quicker than this (milisec)
+ * @param {boolean} [opts.repeat] if true, the job will be endlessly repeated
+ * @param {boolean} [opts.important] if true, the scheduled job cannot be canceled by a queue.clear()
+ */
+ this.schedule = function (fun, opts) {
+ queue.push({fun:fun, opts:opts || {}});
+ if(!running){
+ run();
+ }
+ };
+
+ // remove all jobs from the schedule (except the ones marked as important)
+ this.clear = function(){
+ queue = _.filter(queue,function(job){return job.opts.important === true;});
+ };
+
+ // end the repetition of the current job
+ this.stoprepeat = function(){
+ stoprepeat = true;
+ };
+
+ /**
+ * Returns a promise that resolves when all scheduled jobs have been run.
+ * (jobs added after the call to this method are considered as well)
+ *
+ * @returns {Promise}
+ */
+ this.finished = function () {
+ return end_of_queue;
+ };
+
+};
+
+
+// this object interfaces with the local proxy to communicate to the various hardware devices
+// connected to the Point of Sale. As the communication only goes from the POS to the proxy,
+// methods are used both to signal an event, and to fetch information.
+
+var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
+ init: function(parent,options){
+ mixins.PropertiesMixin.init.call(this);
+ var self = this;
+ this.setParent(parent);
+ options = options || {};
+
+ this.pos = parent;
+
+ this.weighing = false;
+ this.debug_weight = 0;
+ this.use_debug_weight = false;
+
+ this.paying = false;
+ this.default_payment_status = {
+ status: 'waiting',
+ message: '',
+ payment_method: undefined,
+ receipt_client: undefined,
+ receipt_shop: undefined,
+ };
+ this.custom_payment_status = this.default_payment_status;
+
+ this.notifications = {};
+ this.bypass_proxy = false;
+
+ this.connection = null;
+ this.host = '';
+ this.keptalive = false;
+
+ this.set('status',{});
+
+ this.set_connection_status('disconnected');
+
+ this.on('change:status',this,function(eh,status){
+ status = status.newValue;
+ if(status.status === 'connected' && self.printer) {
+ self.printer.print_receipt();
+ }
+ });
+
+ this.posbox_supports_display = true;
+
+ window.hw_proxy = this;
+ },
+ set_connection_status: function(status, drivers, msg=''){
+ var oldstatus = this.get('status');
+ var newstatus = {};
+ newstatus.status = status;
+ newstatus.drivers = status === 'disconnected' ? {} : oldstatus.drivers;
+ newstatus.drivers = drivers ? drivers : newstatus.drivers;
+ newstatus.msg = msg;
+ this.set('status',newstatus);
+ },
+ disconnect: function(){
+ if(this.get('status').status !== 'disconnected'){
+ this.connection.destroy();
+ this.set_connection_status('disconnected');
+ }
+ },
+
+ /**
+ * Connects to the specified url.
+ *
+ * @param {string} url
+ * @returns {Promise}
+ */
+ connect: function(url){
+ var self = this;
+ this.connection = new Session(undefined,url, { use_cors: true});
+ this.host = url;
+ if (this.pos.config.iface_print_via_proxy) {
+ this.connect_to_printer();
+ }
+ this.set_connection_status('connecting',{});
+
+ return this.message('handshake').then(function(response){
+ if(response){
+ self.set_connection_status('connected');
+ localStorage.hw_proxy_url = url;
+ self.keepalive();
+ }else{
+ self.set_connection_status('disconnected');
+ console.error('Connection refused by the Proxy');
+ }
+ },function(){
+ self.set_connection_status('disconnected');
+ console.error('Could not connect to the Proxy');
+ });
+ },
+
+ connect_to_printer: function () {
+ this.printer = new Printer(this.host, this.pos);
+ },
+
+ /**
+ * Find a proxy and connects to it.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.force_ip] only try to connect to the specified ip.
+ * @param {string} [options.port] @see find_proxy
+ * @param {function} [options.progress] @see find_proxy
+ * @returns {Promise}
+ */
+ autoconnect: function (options) {
+ var self = this;
+ this.set_connection_status('connecting',{});
+ if (this.pos.config.iface_print_via_proxy) {
+ this.connect_to_printer();
+ }
+ var found_url = new Promise(function () {});
+
+ if (options.force_ip) {
+ // if the ip is forced by server config, bailout on fail
+ found_url = this.try_hard_to_connect(options.force_ip, options);
+ } else if (localStorage.hw_proxy_url) {
+ // try harder when we remember a good proxy url
+ found_url = this.try_hard_to_connect(localStorage.hw_proxy_url, options)
+ .catch(function () {
+ if (window.location.protocol != 'https:') {
+ return self.find_proxy(options);
+ }
+ });
+ } else {
+ // just find something quick
+ if (window.location.protocol != 'https:'){
+ found_url = this.find_proxy(options);
+ }
+ }
+
+ var successProm = found_url.then(function (url) {
+ return self.connect(url);
+ });
+
+ successProm.catch(function () {
+ self.set_connection_status('disconnected');
+ });
+
+ return successProm;
+ },
+
+ // starts a loop that updates the connection status
+ keepalive: function () {
+ var self = this;
+
+ function status(){
+ var always = function () {
+ setTimeout(status, 5000);
+ };
+ self.connection.rpc('/hw_proxy/status_json',{},{shadow: true, timeout:2500})
+ .then(function (driver_status) {
+ self.set_connection_status('connected',driver_status);
+ }, function () {
+ if(self.get('status').status !== 'connecting'){
+ self.set_connection_status('disconnected');
+ }
+ }).then(always, always);
+ }
+
+ if (!this.keptalive) {
+ this.keptalive = true;
+ status();
+ }
+ },
+
+ /**
+ * @param {string} name
+ * @param {Object} [params]
+ * @returns {Promise}
+ */
+ message : function (name, params) {
+ var callbacks = this.notifications[name] || [];
+ for (var i = 0; i < callbacks.length; i++) {
+ callbacks[i](params);
+ }
+ if (this.get('status').status !== 'disconnected') {
+ return this.connection.rpc('/hw_proxy/' + name, params || {}, {shadow: true});
+ } else {
+ return Promise.reject();
+ }
+ },
+
+ /**
+ * Tries several time to connect to a known proxy url.
+ *
+ * @param {*} url
+ * @param {Object} [options]
+ * @param {string} [options.port=8069] what port to listen to
+ * @returns {Promise<string|Array>}
+ */
+ try_hard_to_connect: function (url, options) {
+ options = options || {};
+ var protocol = window.location.protocol;
+ var port = ( !options.port && protocol == "https:") ? ':443' : ':' + (options.port || '8069');
+
+ this.set_connection_status('connecting');
+
+ if(url.indexOf('//') < 0){
+ url = protocol + '//' + url;
+ }
+
+ if(url.indexOf(':',5) < 0){
+ url = url + port;
+ }
+
+ // try real hard to connect to url, with a 1sec timeout and up to 'retries' retries
+ function try_real_hard_to_connect(url, retries) {
+ return Promise.resolve(
+ $.ajax({
+ url: url + '/hw_proxy/hello',
+ method: 'GET',
+ timeout: 1000,
+ })
+ .then(function () {
+ return Promise.resolve(url);
+ }, function (resp) {
+ if (retries > 0) {
+ return try_real_hard_to_connect(url, retries-1);
+ } else {
+ return Promise.reject([resp.statusText, url]);
+ }
+ })
+ );
+ }
+
+ return try_real_hard_to_connect(url, 3);
+ },
+
+ /**
+ * Returns as a promise a valid host url that can be used as proxy.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.port] what port to listen to (default 8069)
+ * @param {function} [options.progress] callback for search progress ( fac in [0,1] )
+ * @returns {Promise<string>} will be resolved with the proxy valid url
+ */
+ find_proxy: function(options){
+ options = options || {};
+ var self = this;
+ var port = ':' + (options.port || '8069');
+ var urls = [];
+ var found = false;
+ var parallel = 8;
+ var threads = [];
+ var progress = 0;
+
+
+ urls.push('http://localhost'+port);
+ for(var i = 0; i < 256; i++){
+ urls.push('http://192.168.0.'+i+port);
+ urls.push('http://192.168.1.'+i+port);
+ urls.push('http://10.0.0.'+i+port);
+ }
+
+ var prog_inc = 1/urls.length;
+
+ function update_progress(){
+ progress = found ? 1 : progress + prog_inc;
+ if(options.progress){
+ options.progress(progress);
+ }
+ }
+
+ function thread () {
+ var url = urls.shift();
+
+ if (!url || found || !self.searching_for_proxy) {
+ return Promise.resolve();
+ }
+
+ return Promise.resolve(
+ $.ajax({
+ url: url + '/hw_proxy/hello',
+ method: 'GET',
+ timeout: 400,
+ }).then(function () {
+ found = true;
+ update_progress();
+ return Promise.resolve(url);
+ }, function () {
+ update_progress();
+ return thread();
+ })
+ );
+ }
+
+ this.searching_for_proxy = true;
+
+ var len = Math.min(parallel, urls.length);
+ for(i = 0; i < len; i++){
+ threads.push(thread());
+ }
+
+ return new Promise(function (resolve, reject) {
+ Promise.all(threads).then(function(results){
+ var urls = [];
+ for(var i = 0; i < results.length; i++){
+ if(results[i]){
+ urls.push(results[i]);
+ }
+ }
+ resolve(urls[0]);
+ });
+ });
+ },
+
+ stop_searching: function(){
+ this.searching_for_proxy = false;
+ this.set_connection_status('disconnected');
+ },
+
+ // this allows the client to be notified when a proxy call is made. The notification
+ // callback will be executed with the same arguments as the proxy call
+ add_notification: function(name, callback){
+ if(!this.notifications[name]){
+ this.notifications[name] = [];
+ }
+ this.notifications[name].push(callback);
+ },
+
+ /**
+ * Returns the weight on the scale.
+ *
+ * @returns {Promise<Object>}
+ */
+ scale_read: function () {
+ var self = this;
+ if (self.use_debug_weight) {
+ return Promise.resolve({weight:this.debug_weight, unit:'Kg', info:'ok'});
+ }
+ return new Promise(function (resolve, reject) {
+ self.message('scale_read',{})
+ .then(function (weight) {
+ resolve(weight);
+ }, function () { //failed to read weight
+ resolve({weight:0.0, unit:'Kg', info:'ok'});
+ });
+ });
+ },
+
+ // sets a custom weight, ignoring the proxy returned value.
+ debug_set_weight: function(kg){
+ this.use_debug_weight = true;
+ this.debug_weight = kg;
+ },
+
+ // resets the custom weight and re-enable listening to the proxy for weight values
+ debug_reset_weight: function(){
+ this.use_debug_weight = false;
+ this.debug_weight = 0;
+ },
+
+ update_customer_facing_display: function(html) {
+ if (this.posbox_supports_display) {
+ return this.message('customer_facing_display',
+ { html: html },
+ { timeout: 5000 });
+ }
+ },
+
+ /**
+ * @param {string} html
+ * @returns {Promise}
+ */
+ take_ownership_over_client_screen: function(html) {
+ return this.message("take_control", { html: html });
+ },
+
+ /**
+ * @returns {Promise}
+ */
+ test_ownership_of_client_screen: function() {
+ if (this.connection) {
+ return this.message("test_ownership", {});
+ }
+ return Promise.reject({abort: true});
+ },
+
+ // asks the proxy to log some information, as with the debug.log you can provide several arguments.
+ log: function(){
+ return this.message('log',{'arguments': _.toArray(arguments)});
+ },
+
+});
+
+return {
+ JobQueue: JobQueue,
+ ProxyDevice: ProxyDevice,
+};
+
+});
diff --git a/addons/point_of_sale/static/src/js/keyboard.js b/addons/point_of_sale/static/src/js/keyboard.js
new file mode 100644
index 00000000..a9ea1f55
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/keyboard.js
@@ -0,0 +1,207 @@
+odoo.define('point_of_sale.keyboard', function (require) {
+"use strict";
+
+var Widget = require('web.Widget');
+
+// ---------- OnScreen Keyboard Widget ----------
+// A Widget that displays an onscreen keyboard.
+// There are two options when creating the widget :
+//
+// * 'keyboard_model' : 'simple' (default) | 'full'
+// The 'full' emulates a PC keyboard, while 'simple' emulates an 'android' one.
+//
+// * 'input_selector : (default: '.searchbox input')
+// defines the dom element that the keyboard will write to.
+//
+// The widget is initially hidden. It can be shown with this.show(), and is
+// automatically shown when the input_selector gets focused.
+
+var OnscreenKeyboardWidget = Widget.extend({
+ template: 'OnscreenKeyboardSimple',
+ init: function(parent, options){
+ this._super(parent,options);
+ options = options || {};
+
+ this.keyboard_model = options.keyboard_model || 'simple';
+ if(this.keyboard_model === 'full'){
+ this.template = 'OnscreenKeyboardFull';
+ }
+
+ this.input_selector = options.input_selector || '.searchbox input';
+ this.$target = null;
+
+ //Keyboard state
+ this.capslock = false;
+ this.shift = false;
+ this.numlock = false;
+ },
+
+ connect : function(target){
+ var self = this;
+ this.$target = $(target);
+ this.$target.focus(function(){self.show();});
+ },
+ generateEvent: function(type,key){
+ var event = document.createEvent("KeyboardEvent");
+ var initMethod = event.initKeyboardEvent ? 'initKeyboardEvent' : 'initKeyEvent';
+ event[initMethod]( type,
+ true, //bubbles
+ true, //cancelable
+ window, //viewArg
+ false, //ctrl
+ false, //alt
+ false, //shift
+ false, //meta
+ ((typeof key.code === 'undefined') ? key.char.charCodeAt(0) : key.code),
+ ((typeof key.char === 'undefined') ? String.fromCharCode(key.code) : key.char)
+ );
+ return event;
+
+ },
+
+ // Write a character to the input zone
+ writeCharacter: function(character){
+ var input = this.$target[0];
+ input.dispatchEvent(this.generateEvent('keypress',{char: character}));
+ if(character !== '\n'){
+ input.value += character;
+ }
+ input.dispatchEvent(this.generateEvent('keyup',{char: character}));
+ },
+
+ // Removes the last character from the input zone.
+ deleteCharacter: function(){
+ var input = this.$target[0];
+ input.dispatchEvent(this.generateEvent('keypress',{code: 8}));
+ input.value = input.value.substr(0, input.value.length -1);
+ input.dispatchEvent(this.generateEvent('keyup',{code: 8}));
+ },
+
+ // Clears the content of the input zone.
+ deleteAllCharacters: function(){
+ var input = this.$target[0];
+ if(input.value){
+ input.dispatchEvent(this.generateEvent('keypress',{code: 8}));
+ input.value = "";
+ input.dispatchEvent(this.generateEvent('keyup',{code: 8}));
+ }
+ },
+
+ // Makes the keyboard show and slide from the bottom of the screen.
+ show: function(){
+ $('.keyboard_frame').show().css({'height':'235px'});
+ },
+
+ // Makes the keyboard hide by sliding to the bottom of the screen.
+ hide: function(){
+ $('.keyboard_frame')
+ .css({'height':'0'})
+ .hide();
+ this.reset();
+ },
+
+ //What happens when the shift key is pressed : toggle case, remove capslock
+ toggleShift: function(){
+ $('.letter').toggleClass('uppercase');
+ $('.symbol span').toggle();
+
+ this.shift = (this.shift === true) ? false : true;
+ this.capslock = false;
+ },
+
+ //what happens when capslock is pressed : toggle case, set capslock
+ toggleCapsLock: function(){
+ $('.letter').toggleClass('uppercase');
+ this.capslock = true;
+ },
+
+ //What happens when numlock is pressed : toggle symbols and numlock label
+ toggleNumLock: function(){
+ $('.symbol span').toggle();
+ $('.numlock span').toggle();
+ this.numlock = (this.numlock === true ) ? false : true;
+ },
+
+ //After a key is pressed, shift is disabled.
+ removeShift: function(){
+ if (this.shift === true) {
+ $('.symbol span').toggle();
+ if (this.capslock === false) $('.letter').toggleClass('uppercase');
+
+ this.shift = false;
+ }
+ },
+
+ // Resets the keyboard to its original state; capslock: false, shift: false, numlock: false
+ reset: function(){
+ if(this.shift){
+ this.toggleShift();
+ }
+ if(this.capslock){
+ this.toggleCapsLock();
+ }
+ if(this.numlock){
+ this.toggleNumLock();
+ }
+ },
+
+ //called after the keyboard is in the DOM, sets up the key bindings.
+ start: function(){
+ var self = this;
+
+ //this.show();
+
+
+ $('.close_button').click(function(){
+ self.deleteAllCharacters();
+ self.hide();
+ });
+
+ // Keyboard key click handling
+ $('.keyboard li').click(function(){
+
+ var $this = $(this),
+ character = $this.html(); // If it's a lowercase letter, nothing happens to this variable
+
+ if ($this.hasClass('left-shift') || $this.hasClass('right-shift')) {
+ self.toggleShift();
+ return false;
+ }
+
+ if ($this.hasClass('capslock')) {
+ self.toggleCapsLock();
+ return false;
+ }
+
+ if ($this.hasClass('delete')) {
+ self.deleteCharacter();
+ return false;
+ }
+
+ if ($this.hasClass('numlock')){
+ self.toggleNumLock();
+ return false;
+ }
+
+ // Special characters
+ if ($this.hasClass('symbol')) character = $('span:visible', $this).html();
+ if ($this.hasClass('space')) character = ' ';
+ if ($this.hasClass('tab')) character = "\t";
+ if ($this.hasClass('return')) character = "\n";
+
+ // Uppercase letter
+ if ($this.hasClass('uppercase')) character = character.toUpperCase();
+
+ // Remove shift once a key is clicked.
+ self.removeShift();
+
+ self.writeCharacter(character);
+ });
+ },
+});
+
+return {
+ OnscreenKeyboardWidget: OnscreenKeyboardWidget,
+};
+
+});
diff --git a/addons/point_of_sale/static/src/js/main.js b/addons/point_of_sale/static/src/js/main.js
new file mode 100644
index 00000000..346a6167
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/main.js
@@ -0,0 +1,49 @@
+odoo.define('web.web_client', function (require) {
+ 'use strict';
+
+ const AbstractService = require('web.AbstractService');
+ 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');
+
+ owl.config.mode = env.isDebug() ? 'dev' : 'prod';
+ owl.Component.env = env;
+
+ Registries.Component.add(owl.misc.Portal);
+
+ function setupResponsivePlugin(env) {
+ const isMobile = () => window.innerWidth <= 768;
+ env.isMobile = isMobile();
+ const updateEnv = owl.utils.debounce(() => {
+ if (env.isMobile !== isMobile()) {
+ env.isMobile = !env.isMobile;
+ env.qweb.forceUpdate();
+ }
+ }, 15);
+ window.addEventListener("resize", updateEnv);
+ }
+
+ setupResponsivePlugin(owl.Component.env);
+
+ async function startPosApp(webClient) {
+ Registries.Component.freeze();
+ await env.session.is_bound;
+ env.qweb.addTemplates(env.session.owlTemplates);
+ env.bus = new owl.core.EventBus();
+ await owl.utils.whenReady();
+ await webClient.setElement(document.body);
+ await webClient.start();
+ webClient.isStarted = true;
+ const chrome = new (Registries.Component.get(Chrome))(null, { webClient });
+ await chrome.mount(document.querySelector('.o_action_manager'));
+ await chrome.start();
+ configureGui({ component: chrome });
+ }
+
+ AbstractService.prototype.deployServices(env);
+ 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
new file mode 100644
index 00000000..100596d5
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -0,0 +1,3514 @@
+odoo.define('point_of_sale.models', function (require) {
+"use strict";
+
+const { Context } = owl;
+var BarcodeParser = require('barcodes.BarcodeParser');
+var BarcodeReader = require('point_of_sale.BarcodeReader');
+var PosDB = require('point_of_sale.DB');
+var devices = require('point_of_sale.devices');
+var concurrency = require('web.concurrency');
+var config = require('web.config');
+var core = require('web.core');
+var field_utils = require('web.field_utils');
+var time = require('web.time');
+var utils = require('web.utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+var Mutex = concurrency.Mutex;
+var round_di = utils.round_decimals;
+var round_pr = utils.round_precision;
+
+var exports = {};
+
+// The PosModel contains the Point Of Sale's representation of the backend.
+// Since the PoS must work in standalone ( Without connection to the server )
+// it must contains a representation of the server's PoS backend.
+// (taxes, product list, configuration options, etc.) this representation
+// is fetched and stored by the PosModel at the initialisation.
+// this is done asynchronously, a ready deferred alows the GUI to wait interactively
+// for the loading to be completed
+// There is a single instance of the PosModel for each Front-End instance, it is usually called
+// 'pos' and is available to all widgets extending PosWidget.
+
+exports.PosModel = Backbone.Model.extend({
+ 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.env = this.get('env');
+ 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});
+
+ this.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy
+ this.db = new PosDB(); // a local database used to search trough products and categories & store pending orders
+ this.debug = config.isDebug(); //debug mode
+
+ // Business data; loaded from the server at launch
+ this.company_logo = null;
+ this.company_logo_base64 = '';
+ this.currency = null;
+ this.company = null;
+ this.user = null;
+ this.users = [];
+ this.employee = {name: null, id: null, barcode: null, user_id:null, pin:null};
+ this.employees = [];
+ this.partners = [];
+ this.taxes = [];
+ this.pos_session = null;
+ this.config = null;
+ this.units = [];
+ this.units_by_id = {};
+ this.uom_unit_id = null;
+ this.default_pricelist = null;
+ this.order_sequence = 1;
+ window.posmodel = this;
+
+ // Object mapping the order's name (which contains the uid) to it's server_id after
+ // validation (order paid then sent to the backend).
+ this.validated_orders_name_server_id_map = {};
+
+ // Extract the config id from the url.
+ var given_config = new RegExp('[\?&]config_id=([^&#]*)').exec(window.location.href);
+ this.config_id = given_config && given_config[1] && parseInt(given_config[1]) || false;
+
+ // these dynamic attributes can be watched for change by other models or widgets
+ this.set({
+ 'synch': { status:'connected', pending:0 },
+ 'orders': new OrderCollection(),
+ 'selectedOrder': null,
+ 'selectedClient': null,
+ 'cashier': null,
+ 'selectedCategoryId': null,
+ });
+
+ this.get('orders').on('remove', function(order,_unused_,options){
+ self.on_removed_order(order,options.index,options.reason);
+ });
+
+ // Forward the 'client' attribute on the selected order to 'selectedClient'
+ function update_client() {
+ var order = self.get_order();
+ this.set('selectedClient', order ? order.get_client() : null );
+ }
+ 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.
+ // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used.
+ this.ready = this.load_server_data().then(function(){
+ return self.after_load_server_data();
+ });
+ },
+ after_load_server_data: function(){
+ this.load_orders();
+ this.set_start_order();
+ if(this.config.use_proxy){
+ if (this.config.iface_customer_facing_display) {
+ this.on('change:selectedOrder', this.send_current_order_to_customer_facing_display, this);
+ }
+
+ return this.connect_to_proxy();
+ }
+ return Promise.resolve();
+ },
+ // releases ressources holds by the model at the end of life of the posmodel
+ destroy: function(){
+ // FIXME, should wait for flushing, return a deferred to indicate successfull destruction
+ // this.flush();
+ this.proxy.disconnect();
+ this.barcode_reader.disconnect_from_proxy();
+ },
+
+ connect_to_proxy: function () {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.barcode_reader.disconnect_from_proxy();
+ 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.setLoadingProgress(prog);
+ },
+ }).then(
+ function () {
+ if (self.config.iface_scan_via_proxy) {
+ self.barcode_reader.connect_to_proxy();
+ }
+ resolve();
+ },
+ function (statusText, url) {
+ // 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();
+ }
+ }
+ );
+ });
+ },
+
+ // Server side model loaders. This is the list of the models that need to be loaded from
+ // the server. The models are loaded one by one by this list's order. The 'loaded' callback
+ // is used to store the data in the appropriate place once it has been loaded. This callback
+ // can return a promise that will pause the loading of the next module.
+ // a shared temporary dictionary is available for loaders to communicate private variables
+ // used during loading such as object ids, etc.
+ models: [
+ {
+ label: 'version',
+ loaded: function (self) {
+ return self.session.rpc('/web/webclient/version_info',{}).then(function (version) {
+ self.version = version;
+ });
+ },
+
+ },{
+ 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 [self.session.user_context.allowed_company_ids[0]]; },
+ loaded: function(self,companies){ self.company = companies[0]; },
+ },{
+ model: 'decimal.precision',
+ fields: ['name','digits'],
+ loaded: function(self,dps){
+ self.dp = {};
+ for (var i = 0; i < dps.length; i++) {
+ self.dp[dps[i].name] = dps[i].digits;
+ }
+ },
+ },{
+ model: 'uom.uom',
+ fields: [],
+ domain: null,
+ context: function(self){ return { active_test: false }; },
+ loaded: function(self,units){
+ self.units = units;
+ _.each(units, function(unit){
+ self.units_by_id[unit.id] = unit;
+ });
+ }
+ },{
+ model: 'ir.model.data',
+ fields: ['res_id'],
+ domain: function(){ return [['name', '=', 'product_uom_unit']]; },
+ loaded: function(self,unit){
+ self.uom_unit_id = unit[0].res_id;
+ }
+ },{
+ model: 'res.partner',
+ label: 'load_partners',
+ fields: ['name','street','city','state_id','country_id','vat','lang',
+ 'phone','zip','mobile','email','barcode','write_date',
+ 'property_account_position_id','property_product_pricelist'],
+ loaded: function(self,partners){
+ self.partners = partners;
+ self.db.add_partners(partners);
+ },
+ },{
+ model: 'res.country.state',
+ fields: ['name', 'country_id'],
+ loaded: function(self,states){
+ self.states = states;
+ },
+ },{
+ model: 'res.country',
+ fields: ['name', 'vat_label', 'code'],
+ loaded: function(self,countries){
+ self.countries = countries;
+ self.company.country = null;
+ for (var i = 0; i < countries.length; i++) {
+ if (countries[i].id === self.company.country_id[0]){
+ self.company.country = countries[i];
+ }
+ }
+ },
+ },{
+ model: 'res.lang',
+ fields: ['name', 'code'],
+ loaded: function (self, langs){
+ self.langs = langs;
+ },
+ },{
+ model: 'account.tax',
+ fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'],
+ domain: function(self) {return [['company_id', '=', self.company && self.company.id || false]]},
+ loaded: function(self, taxes){
+ self.taxes = taxes;
+ self.taxes_by_id = {};
+ _.each(taxes, function(tax){
+ self.taxes_by_id[tax.id] = tax;
+ });
+ _.each(self.taxes_by_id, function(tax) {
+ tax.children_tax_ids = _.map(tax.children_tax_ids, function (child_tax_id) {
+ return self.taxes_by_id[child_tax_id];
+ });
+ });
+ return new Promise(function (resolve, reject) {
+ var tax_ids = _.pluck(self.taxes, 'id');
+ self.rpc({
+ model: 'account.tax',
+ method: 'get_real_tax_amount',
+ args: [tax_ids],
+ }).then(function (taxes) {
+ _.each(taxes, function (tax) {
+ self.taxes_by_id[tax.id].amount = tax.amount;
+ });
+ resolve();
+ });
+ });
+ },
+ },{
+ model: 'pos.session',
+ fields: ['id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number', 'payment_method_ids', 'cash_register_id', 'state'],
+ domain: function(self){
+ var domain = [
+ ['state','in',['opening_control','opened']],
+ ['rescue', '=', false],
+ ];
+ if (self.config_id) domain.push(['config_id', '=', self.config_id]);
+ return domain;
+ },
+ loaded: function(self, pos_sessions, tmp){
+ self.pos_session = pos_sessions[0];
+ self.pos_session.login_number = odoo.login_number;
+ self.config_id = self.config_id || self.pos_session && self.pos_session.config_id[0];
+ tmp.payment_method_ids = pos_sessions[0].payment_method_ids;
+ },
+ },{
+ model: 'pos.config',
+ fields: [],
+ domain: function(self){ return [['id','=', self.config_id]]; },
+ loaded: function(self,configs){
+ self.config = configs[0];
+ self.config.use_proxy = self.config.is_posbox && (
+ self.config.iface_electronic_scale ||
+ self.config.iface_print_via_proxy ||
+ self.config.iface_scan_via_proxy ||
+ self.config.iface_customer_facing_display);
+
+ self.db.set_uuid(self.config.uuid);
+ self.set_cashier(self.get_cashier());
+ // We need to do it here, since only then the local storage has the correct uuid
+ self.db.save('pos_session_id', self.pos_session.id);
+
+ var orders = self.db.get_orders();
+ for (var i = 0; i < orders.length; i++) {
+ self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1);
+ }
+ },
+ },{
+ model: 'stock.picking.type',
+ fields: ['use_create_lots', 'use_existing_lots'],
+ domain: function(self){ return [['id', '=', self.config.picking_type_id[0]]]; },
+ loaded: function(self, picking_type) {
+ self.picking_type = picking_type[0];
+ },
+ },{
+ model: 'res.users',
+ fields: ['name','company_id', 'id', 'groups_id', 'lang'],
+ domain: function(self){ return [['company_ids', 'in', self.config.company_id[0]],'|', ['groups_id','=', self.config.group_pos_manager_id[0]],['groups_id','=', self.config.group_pos_user_id[0]]]; },
+ loaded: function(self,users){
+ users.forEach(function(user) {
+ user.role = 'cashier';
+ user.groups_id.some(function(group_id) {
+ if (group_id === self.config.group_pos_manager_id[0]) {
+ user.role = 'manager';
+ return true;
+ }
+ });
+ if (user.id === self.session.uid) {
+ self.user = user;
+ self.employee.name = user.name;
+ self.employee.role = user.role;
+ self.employee.user_id = [user.id, user.name];
+ }
+ });
+ self.users = users;
+ self.employees = [self.employee];
+ self.set_cashier(self.employee);
+ },
+ },{
+ model: 'product.pricelist',
+ fields: ['name', 'display_name', 'discount_policy'],
+ domain: function(self) {
+ if (self.config.use_pricelist) {
+ return [['id', 'in', self.config.available_pricelist_ids]];
+ } else {
+ return [['id', '=', self.config.pricelist_id[0]]];
+ }
+ },
+ loaded: function(self, pricelists){
+ _.map(pricelists, function (pricelist) { pricelist.items = []; });
+ self.default_pricelist = _.findWhere(pricelists, {id: self.config.pricelist_id[0]});
+ self.pricelists = pricelists;
+ },
+ },{
+ model: 'account.bank.statement',
+ fields: ['id', 'balance_start'],
+ domain: function(self){ return [['id', '=', self.pos_session.cash_register_id[0]]]; },
+ loaded: function(self, statement){
+ self.bank_statement = statement[0];
+ },
+ },{
+ model: 'product.pricelist.item',
+ domain: function(self) { return [['pricelist_id', 'in', _.pluck(self.pricelists, 'id')]]; },
+ loaded: function(self, pricelist_items){
+ var pricelist_by_id = {};
+ _.each(self.pricelists, function (pricelist) {
+ pricelist_by_id[pricelist.id] = pricelist;
+ });
+
+ _.each(pricelist_items, function (item) {
+ var pricelist = pricelist_by_id[item.pricelist_id[0]];
+ pricelist.items.push(item);
+ item.base_pricelist = pricelist_by_id[item.base_pricelist_id[0]];
+ });
+ },
+ },{
+ model: 'product.category',
+ fields: ['name', 'parent_id'],
+ loaded: function(self, product_categories){
+ var category_by_id = {};
+ _.each(product_categories, function (category) {
+ category_by_id[category.id] = category;
+ });
+ _.each(product_categories, function (category) {
+ category.parent = category_by_id[category.parent_id[0]];
+ });
+
+ self.product_categories = product_categories;
+ },
+ },{
+ model: 'res.currency',
+ fields: ['name','symbol','position','rounding','rate'],
+ ids: function(self){ return [self.config.currency_id[0], self.company.currency_id[0]]; },
+ loaded: function(self, currencies){
+ self.currency = currencies[0];
+ if (self.currency.rounding > 0 && self.currency.rounding < 1) {
+ self.currency.decimals = Math.ceil(Math.log(1.0 / self.currency.rounding) / Math.log(10));
+ } else {
+ self.currency.decimals = 0;
+ }
+
+ self.company_currency = currencies[1];
+ },
+ },{
+ model: 'pos.category',
+ 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]] : [];
+ },
+ loaded: function(self, categories){
+ self.db.add_categories(categories);
+ },
+ },{
+ 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', 'write_date', 'available_in_pos', 'attribute_line_ids'],
+ 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]];
+ if (self.config.limit_categories && self.config.iface_available_categ_ids.length) {
+ domain.unshift('&');
+ domain.push(['pos_categ_id', 'in', self.config.iface_available_categ_ids]);
+ }
+ if (self.config.iface_tipproduct){
+ domain.unshift(['id', '=', self.config.tip_product_id[0]]);
+ domain.unshift('|');
+ }
+ return domain;
+ },
+ context: function(self){ return { display_default_code: false }; },
+ loaded: function(self, products){
+ var using_company_currency = self.config.currency_id[0] === self.company.currency_id[0];
+ var conversion_rate = self.currency.rate / self.company_currency.rate;
+ self.db.add_products(_.map(products, function (product) {
+ if (!using_company_currency) {
+ 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);
+ }));
+ },
+ },{
+ model: 'product.attribute',
+ fields: ['name', 'display_type'],
+ condition: function (self) { return self.config.product_configurator; },
+ domain: function(){ return [['create_variant', '=', 'no_variant']]; },
+ loaded: function(self, product_attributes, tmp) {
+ tmp.product_attributes_by_id = {};
+ _.map(product_attributes, function (product_attribute) {
+ tmp.product_attributes_by_id[product_attribute.id] = product_attribute;
+ });
+ }
+ },{
+ model: 'product.attribute.value',
+ fields: ['name', 'attribute_id', 'is_custom', 'html_color'],
+ condition: function (self) { return self.config.product_configurator; },
+ domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; },
+ loaded: function(self, pavs, tmp) {
+ tmp.pav_by_id = {};
+ _.map(pavs, function (pav) {
+ tmp.pav_by_id[pav.id] = pav;
+ });
+ }
+ }, {
+ model: 'product.template.attribute.value',
+ fields: ['product_attribute_value_id', 'attribute_id', 'attribute_line_id', 'price_extra'],
+ condition: function (self) { return self.config.product_configurator; },
+ domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; },
+ loaded: function(self, ptavs, tmp) {
+ self.attributes_by_ptal_id = {};
+ _.map(ptavs, function (ptav) {
+ if (!self.attributes_by_ptal_id[ptav.attribute_line_id[0]]){
+ self.attributes_by_ptal_id[ptav.attribute_line_id[0]] = {
+ id: ptav.attribute_line_id[0],
+ name: tmp.product_attributes_by_id[ptav.attribute_id[0]].name,
+ display_type: tmp.product_attributes_by_id[ptav.attribute_id[0]].display_type,
+ values: [],
+ };
+ }
+ self.attributes_by_ptal_id[ptav.attribute_line_id[0]].values.push({
+ id: ptav.product_attribute_value_id[0],
+ name: tmp.pav_by_id[ptav.product_attribute_value_id[0]].name,
+ is_custom: tmp.pav_by_id[ptav.product_attribute_value_id[0]].is_custom,
+ html_color: tmp.pav_by_id[ptav.product_attribute_value_id[0]].html_color,
+ price_extra: ptav.price_extra,
+ });
+ });
+ }
+ },{
+ model: 'account.cash.rounding',
+ fields: ['name', 'rounding', 'rounding_method'],
+ domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; },
+ loaded: function(self, cash_rounding) {
+ self.cash_rounding = cash_rounding;
+ }
+ },{
+ model: 'pos.payment.method',
+ fields: ['name', 'is_cash_count', 'use_payment_terminal'],
+ domain: function(self){return ['|',['active', '=', false], ['active', '=', true]]; },
+ loaded: function(self, payment_methods) {
+ self.payment_methods = payment_methods.sort(function(a,b){
+ // prefer cash payment_method to be first in the list
+ if (a.is_cash_count && !b.is_cash_count) {
+ return -1;
+ } else if (!a.is_cash_count && b.is_cash_count) {
+ return 1;
+ } else {
+ return a.id - b.id;
+ }
+ });
+ self.payment_methods_by_id = {};
+ _.each(self.payment_methods, function(payment_method) {
+ self.payment_methods_by_id[payment_method.id] = payment_method;
+
+ var PaymentInterface = self.electronic_payment_interfaces[payment_method.use_payment_terminal];
+ if (PaymentInterface) {
+ payment_method.payment_terminal = new PaymentInterface(self, payment_method);
+ }
+ });
+ }
+ },{
+ model: 'account.fiscal.position',
+ fields: [],
+ domain: function(self){ return [['id','in',self.config.fiscal_position_ids]]; },
+ loaded: function(self, fiscal_positions){
+ self.fiscal_positions = fiscal_positions;
+ }
+ }, {
+ model: 'account.fiscal.position.tax',
+ fields: [],
+ domain: function(self){
+ var fiscal_position_tax_ids = [];
+
+ self.fiscal_positions.forEach(function (fiscal_position) {
+ fiscal_position.tax_ids.forEach(function (tax_id) {
+ fiscal_position_tax_ids.push(tax_id);
+ });
+ });
+
+ return [['id','in',fiscal_position_tax_ids]];
+ },
+ loaded: function(self, fiscal_position_taxes){
+ self.fiscal_position_taxes = fiscal_position_taxes;
+ self.fiscal_positions.forEach(function (fiscal_position) {
+ fiscal_position.fiscal_position_taxes_by_id = {};
+ fiscal_position.tax_ids.forEach(function (tax_id) {
+ var fiscal_position_tax = _.find(fiscal_position_taxes, function (fiscal_position_tax) {
+ return fiscal_position_tax.id === tax_id;
+ });
+
+ fiscal_position.fiscal_position_taxes_by_id[fiscal_position_tax.id] = fiscal_position_tax;
+ });
+ });
+ }
+ }, {
+ label: 'fonts',
+ loaded: function(){
+ return new Promise(function (resolve, reject) {
+ // Waiting for fonts to be loaded to prevent receipt printing
+ // from printing empty receipt while loading Inconsolata
+ // ( The font used for the receipt )
+ waitForWebfonts(['Lato','Inconsolata'], function () {
+ resolve();
+ });
+ // The JS used to detect font loading is not 100% robust, so
+ // do not wait more than 5sec
+ setTimeout(resolve, 5000);
+ });
+ },
+ },{
+ label: 'pictures',
+ loaded: function (self) {
+ self.company_logo = new Image();
+ return new Promise(function (resolve, reject) {
+ self.company_logo.onload = function () {
+ var img = self.company_logo;
+ var ratio = 1;
+ var targetwidth = 300;
+ var maxheight = 150;
+ if( img.width !== targetwidth ){
+ ratio = targetwidth / 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);
+ var c = document.createElement('canvas');
+ c.width = width;
+ c.height = height;
+ var ctx = c.getContext('2d');
+ ctx.drawImage(self.company_logo,0,0, width, height);
+
+ self.company_logo_base64 = c.toDataURL();
+ resolve();
+ };
+ self.company_logo.onerror = function () {
+ reject();
+ };
+ self.company_logo.crossOrigin = "anonymous";
+ self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + self.session.db + '&company=' + self.company.id + '&_' + Math.random();
+ });
+ },
+ }, {
+ label: 'barcodes',
+ loaded: function(self) {
+ var barcode_parser = new BarcodeParser({'nomenclature_id': self.config.barcode_nomenclature_id});
+ self.barcode_reader.set_barcode_parser(barcode_parser);
+ return barcode_parser.is_loaded();
+ },
+ },
+ ],
+
+ // loads all the needed data on the sever. returns a promise indicating when all the data has loaded.
+ load_server_data: function(){
+ var self = this;
+ var progress = 0;
+ var progress_step = 1.0 / self.models.length;
+ var tmp = {}; // this is used to share a temporary state between models loaders
+
+ var loaded = new Promise(function (resolve, reject) {
+ function load_model(index) {
+ if (index >= self.models.length) {
+ resolve();
+ } else {
+ var model = self.models[index];
+ self.setLoadingMessage(_t('Loading')+' '+(model.label || model.model || ''), progress);
+
+ var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true;
+ if (!cond) {
+ load_model(index+1);
+ return;
+ }
+
+ var fields = typeof model.fields === 'function' ? model.fields(self,tmp) : model.fields;
+ var domain = typeof model.domain === 'function' ? model.domain(self,tmp) : model.domain;
+ var context = typeof model.context === 'function' ? model.context(self,tmp) : model.context || {};
+ var ids = typeof model.ids === 'function' ? model.ids(self,tmp) : model.ids;
+ var order = typeof model.order === 'function' ? model.order(self,tmp): model.order;
+ progress += progress_step;
+
+ if( model.model ){
+ var params = {
+ model: model.model,
+ context: _.extend(context, self.session.user_context || {}),
+ };
+
+ if (model.ids) {
+ params.method = 'read';
+ params.args = [ids, fields];
+ } else {
+ params.method = 'search_read';
+ params.domain = domain;
+ params.fields = fields;
+ params.orderBy = order;
+ }
+
+ 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); },
+ function (err) { reject(err); });
+ } catch (err) {
+ console.error(err.message, err.stack);
+ reject(err);
+ }
+ }, function (err) {
+ reject(err);
+ });
+ } else if (model.loaded) {
+ try { // catching exceptions in model.loaded(...)
+ Promise.resolve(model.loaded(self, tmp))
+ .then(function () { load_model(index +1); },
+ function (err) { reject(err); });
+ } catch (err) {
+ reject(err);
+ }
+ } else {
+ load_model(index + 1);
+ }
+ }
+ }
+
+ try {
+ return load_model(0);
+ } catch (err) {
+ return Promise.reject(err);
+ }
+ });
+
+ return loaded;
+ },
+
+ prepare_new_partners_domain: function(){
+ return [['write_date','>', this.db.get_partner_write_date()]];
+ },
+
+ // reload the list of partner, returns as a promise that resolves if there were
+ // updated partners, and fails if not
+ load_new_partners: function(){
+ var self = this;
+ 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();
+ self.rpc({
+ model: 'res.partner',
+ method: 'search_read',
+ args: [domain, fields],
+ }, {
+ timeout: 3000,
+ shadow: true,
+ })
+ .then(function (partners) {
+ if (self.db.add_partners(partners)) { // check if the partners we got were real updates
+ resolve();
+ } else {
+ reject('Failed in updating partners.');
+ }
+ }, function (type, err) { reject(); });
+ });
+ },
+
+ // this is called when an order is removed from the order collection. It ensures that there is always an existing
+ // order and a valid selected order
+ on_removed_order: function(removed_order,index,reason){
+ var order_list = this.get_order_list();
+ if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){
+ // when we intentionally remove an unfinished order, and there is another existing one
+ this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true });
+ }else{
+ // when the order was automatically removed after completion,
+ // or when we intentionally delete the only concurrent order
+ this.add_new_order({ silent: true });
+ }
+ },
+
+ // returns the user who is currently the cashier for this point of sale
+ get_cashier: function(){
+ // reset the cashier to the current user if session is new
+ if (this.db.load('pos_session_id') !== this.pos_session.id) {
+ this.set_cashier(this.employee);
+ }
+ return this.db.get_cashier() || this.get('cashier') || this.employee;
+ },
+ // changes the current cashier
+ set_cashier: function(employee){
+ this.set('cashier', employee);
+ this.db.set_cashier(this.get('cashier'));
+ },
+ // creates a new empty order and sets it as the current order
+ add_new_order: function(options){
+ var order = new exports.Order({},{pos:this});
+ this.get('orders').add(order);
+ this.set('selectedOrder', order, options);
+ return order;
+ },
+ /**
+ * Load the locally saved unpaid orders for this PoS Config.
+ *
+ * First load all orders belonging to the current session.
+ * Second load all orders belonging to the same config but from other sessions,
+ * Only if tho order has orderlines.
+ */
+ load_orders: function(){
+ var jsons = this.db.get_unpaid_orders();
+ var orders = [];
+
+ for (var i = 0; i < jsons.length; i++) {
+ var json = jsons[i];
+ if (json.pos_session_id === this.pos_session.id) {
+ orders.push(new exports.Order({},{
+ pos: this,
+ json: json,
+ }));
+ }
+ }
+ for (var i = 0; i < jsons.length; i++) {
+ var json = jsons[i];
+ if (json.pos_session_id !== this.pos_session.id && json.lines.length > 0) {
+ orders.push(new exports.Order({},{
+ pos: this,
+ json: json,
+ }));
+ } else if (json.pos_session_id !== this.pos_session.id) {
+ this.db.remove_unpaid_order(jsons[i]);
+ }
+ }
+
+ orders = orders.sort(function(a,b){
+ return a.sequence_number - b.sequence_number;
+ });
+
+ if (orders.length) {
+ this.get('orders').add(orders);
+ }
+ },
+
+ set_start_order: function(){
+ var orders = this.get('orders').models;
+
+ if (orders.length && !this.get('selectedOrder')) {
+ this.set('selectedOrder',orders[0]);
+ } else {
+ this.add_new_order();
+ }
+ },
+
+ // return the current order
+ get_order: function(){
+ return this.get('selectedOrder');
+ },
+
+ get_client: function() {
+ var order = this.get_order();
+ if (order) {
+ return order.get_client();
+ }
+ return null;
+ },
+
+ // change the current order
+ set_order: function(order, options){
+ this.set({ selectedOrder: order }, options);
+ },
+
+ // return the list of unpaid orders
+ get_order_list: function(){
+ return this.get('orders').models;
+ },
+
+ //removes the current order
+ delete_current_order: function(){
+ var order = this.get_order();
+ if (order) {
+ order.destroy({'reason':'abandon'});
+ }
+ },
+
+ _convert_product_img_to_base64: function (product, url) {
+ return new Promise(function (resolve, reject) {
+ var img = new Image();
+
+ img.onload = function () {
+ var canvas = document.createElement('CANVAS');
+ var ctx = canvas.getContext('2d');
+
+ canvas.height = this.height;
+ canvas.width = this.width;
+ ctx.drawImage(this,0,0);
+
+ var dataURL = canvas.toDataURL('image/jpeg');
+ product.image_base64 = dataURL;
+ canvas = null;
+
+ resolve();
+ };
+ img.crossOrigin = 'use-credentials';
+ img.src = url;
+ });
+ },
+
+ send_current_order_to_customer_facing_display: function() {
+ var self = this;
+ this.render_html_for_customer_facing_display().then(function (rendered_html) {
+ self.proxy.update_customer_facing_display(rendered_html);
+ });
+ },
+
+ /**
+ * @returns {Promise<string>}
+ */
+ render_html_for_customer_facing_display: function () {
+ var self = this;
+ var order = this.get_order();
+ var rendered_html = this.config.customer_facing_display_html;
+
+ // If we're using an external device like the IoT Box, we
+ // cannot get /web/image?model=product.product because the
+ // IoT Box is not logged in and thus doesn't have the access
+ // rights to access product.product. So instead we'll base64
+ // encode it and embed it in the HTML.
+ var get_image_promises = [];
+
+ if (order) {
+ order.get_orderlines().forEach(function (orderline) {
+ var product = orderline.product;
+ 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) {
+ get_image_promises.push(self._convert_product_img_to_base64(product, image_url));
+ }
+ });
+ }
+
+ // when all images are loaded in product.image_base64
+ return Promise.all(get_image_promises).then(function () {
+ var rendered_order_lines = "";
+ var rendered_payment_lines = "";
+ var order_total_with_tax = self.format_currency(0);
+
+ if (order) {
+ rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', {
+ 'orderlines': order.get_orderlines(),
+ 'pos': self,
+ });
+ rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', {
+ 'order': order,
+ 'pos': self,
+ });
+ order_total_with_tax = self.format_currency(order.get_total_with_tax());
+ }
+
+ var $rendered_html = $(rendered_html);
+ $rendered_html.find('.pos_orderlines_list').html(rendered_order_lines);
+ $rendered_html.find('.pos-total').find('.pos_total-amount').html(order_total_with_tax);
+ var pos_change_title = $rendered_html.find('.pos-change_title').text();
+ $rendered_html.find('.pos-paymentlines').html(rendered_payment_lines);
+ $rendered_html.find('.pos-change_title').text(pos_change_title);
+
+ // prop only uses the first element in a set of elements,
+ // and there's no guarantee that
+ // customer_facing_display_html is wrapped in a single
+ // root element.
+ rendered_html = _.reduce($rendered_html, function (memory, current_element) {
+ return memory + $(current_element).prop('outerHTML');
+ }, ""); // initial memory of ""
+
+ rendered_html = QWeb.render('CustomerFacingDisplayHead', {
+ origin: window.location.origin
+ }) + rendered_html;
+ return rendered_html;
+ });
+ },
+
+ // 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_orders: function (order, opts) {
+ opts = opts || {};
+ var self = this;
+
+ if (order) {
+ this.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_orders(), opts);
+
+ flushed.then(resolve, reject);
+
+ return flushed;
+ });
+ });
+ },
+
+ 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 order = self.db.get_order(order_id);
+ if (order){
+ var flushed = self._flush_orders([order], opts);
+ } else {
+ var flushed = Promise.resolve([]);
+ }
+ 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:
+ // error-no-client: the order must have an associated partner_id. You can retry to make an invoice once
+ // this error is solved
+ // error-transfer: there was a connection error during the transfer. You can retry to make the invoice once
+ // the network connection is up
+
+ push_and_invoice_order: function (order) {
+ var self = this;
+ var invoiced = new Promise(function (resolveInvoiced, rejectInvoiced) {
+ if(!order.get_client()){
+ rejectInvoiced({code:400, message:'Missing Customer', data:{}});
+ }
+ else {
+ var order_id = self.db.add_order(order.export_as_JSON());
+
+ self.flush_mutex.exec(function () {
+ var done = new Promise(function (resolveDone, rejectDone) {
+ // send the order to the server
+ // we have a 30 seconds timeout on this push.
+ // FIXME: if the server takes more than 30 seconds to accept the order,
+ // the client will believe it wasn't successfully sent, and very bad
+ // things will happen as a duplicate will be sent next time
+ // so we must make sure the server detects and ignores duplicated orders
+
+ var transfer = self._flush_orders([self.db.get_order(order_id)], {timeout:30000, to_invoice:true});
+
+ transfer.catch(function (error) {
+ rejectInvoiced(error);
+ rejectDone();
+ });
+
+ // on success, get the order id generated by the server
+ transfer.then(function(order_server_id){
+ // generate the pdf and download it
+ if (order_server_id.length) {
+ self.do_action('point_of_sale.pos_invoice_report',{additional_context:{
+ active_ids:order_server_id,
+ }}).then(function () {
+ resolveInvoiced(order_server_id);
+ resolveDone();
+ }).guardedCatch(function (error) {
+ rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}});
+ rejectDone();
+ });
+ } else if (order_server_id.length) {
+ resolveInvoiced(order_server_id);
+ resolveDone();
+ } else {
+ // The order has been pushed separately in batch when
+ // the connection came back.
+ // The user has to go to the backend to print the invoice
+ rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}});
+ rejectDone();
+ }
+ });
+ return done;
+ });
+ });
+ }
+ });
+
+ return invoiced;
+ },
+
+ // 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);
+
+ return this._save_to_server(orders, options).then(function (server_ids) {
+ self.set_synch('connected');
+ for (let i = 0; i < server_ids.length; i++) {
+ self.validated_orders_name_server_id_map[server_ids[i].pos_reference] = server_ids[i].id;
+ }
+ return _.pluck(server_ids, 'id');
+ }).catch(function(error){
+ self.set_synch(self.get('failed') ? 'error' : 'disconnected');
+ return Promise.reject(error);
+ });
+ },
+
+ 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', { status, pending });
+ },
+
+ // send an array of orders to the server
+ // available options:
+ // - timeout: timeout for the rpc call in ms
+ // returns a promise that resolves with the list of
+ // server generated ids for the sent orders
+ _save_to_server: function (orders, options) {
+ if (!orders || !orders.length) {
+ return Promise.resolve([]);
+ }
+
+ options = options || {};
+
+ var self = this;
+ var timeout = typeof options.timeout === 'number' ? options.timeout : 30000 * orders.length;
+
+ // Keep the order ids that are about to be sent to the
+ // backend. In between create_from_ui and the success callback
+ // new orders may have been added to it.
+ var order_ids_to_sync = _.pluck(orders, 'id');
+
+ // we try to send the order. shadow prevents a spinner if it takes too long. (unless we are sending an invoice,
+ // then we want to notify the user that we are waiting on something )
+ var args = [_.map(orders, function (order) {
+ order.to_invoice = options.to_invoice || false;
+ return order;
+ })];
+ args.push(options.draft || false);
+ return this.rpc({
+ model: 'pos.order',
+ method: 'create_from_ui',
+ args: args,
+ kwargs: {context: this.session.user_context},
+ }, {
+ timeout: timeout,
+ shadow: !options.to_invoice
+ })
+ .then(function (server_ids) {
+ _.each(order_ids_to_sync, function (order_id) {
+ self.db.remove_order(order_id);
+ });
+ self.set('failed',false);
+ 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.set('failed',error);
+ throw error;
+ }
+ }
+ throw error;
+ });
+ },
+
+ /**
+ * Remove orders with given ids from the database.
+ * @param {array<number>} server_ids ids of the orders to be removed.
+ * @param {dict} options.
+ * @param {number} options.timeout optional timeout parameter for the rpc call.
+ * @return {Promise<array<number>>} returns a promise of the ids successfully removed.
+ */
+ _remove_from_server: function (server_ids, options) {
+ options = options || {};
+ if (!server_ids || !server_ids.length) {
+ return Promise.resolve([]);
+ }
+
+ var self = this;
+ var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length;
+
+ return this.rpc({
+ model: 'pos.order',
+ method: 'remove_from_ui',
+ args: [server_ids],
+ kwargs: {context: this.session.user_context},
+ }, {
+ timeout: timeout,
+ shadow: true,
+ })
+ .then(function (data) {
+ return self._post_remove_from_server(server_ids, data)
+ }).catch(function (reason){
+ 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;
+ });
+ },
+
+ // to override
+ _post_remove_from_server(server_ids, data) {
+ this.db.set_ids_removed_from_server(server_ids);
+ return server_ids;
+ },
+
+ scan_product: function(parsed_code){
+ var selectedOrder = this.get_order();
+ var product = this.db.get_product_by_barcode(parsed_code.base_code);
+
+ if(!product){
+ return false;
+ }
+
+ if(parsed_code.type === 'price'){
+ selectedOrder.add_product(product, {price:parsed_code.value});
+ }else if(parsed_code.type === 'weight'){
+ selectedOrder.add_product(product, {quantity:parsed_code.value, merge:false});
+ }else if(parsed_code.type === 'discount'){
+ selectedOrder.add_product(product, {discount:parsed_code.value, merge:false});
+ }else{
+ selectedOrder.add_product(product);
+ }
+ return true;
+ },
+
+ // Exports the paid orders (the ones waiting for internet connection)
+ export_paid_orders: function() {
+ return JSON.stringify({
+ 'paid_orders': this.db.get_orders(),
+ 'session': this.pos_session.name,
+ 'session_id': this.pos_session.id,
+ 'date': (new Date()).toUTCString(),
+ 'version': this.version.server_version_info,
+ },null,2);
+ },
+
+ // Exports the unpaid orders (the tabs)
+ export_unpaid_orders: function() {
+ return JSON.stringify({
+ 'unpaid_orders': this.db.get_unpaid_orders(),
+ 'session': this.pos_session.name,
+ 'session_id': this.pos_session.id,
+ 'date': (new Date()).toUTCString(),
+ 'version': this.version.server_version_info,
+ },null,2);
+ },
+
+ // This imports paid or unpaid orders from a json file whose
+ // contents are provided as the string str.
+ // It returns a report of what could and what could not be
+ // imported.
+ import_orders: function(str) {
+ var json = JSON.parse(str);
+ var report = {
+ // Number of paid orders that were imported
+ paid: 0,
+ // Number of unpaid orders that were imported
+ unpaid: 0,
+ // Orders that were not imported because they already exist (uid conflict)
+ unpaid_skipped_existing: 0,
+ // Orders that were not imported because they belong to another session
+ unpaid_skipped_session: 0,
+ // The list of session ids to which skipped orders belong.
+ unpaid_skipped_sessions: [],
+ };
+
+ if (json.paid_orders) {
+ for (var i = 0; i < json.paid_orders.length; i++) {
+ this.db.add_order(json.paid_orders[i].data);
+ }
+ report.paid = json.paid_orders.length;
+ this.push_orders();
+ }
+
+ if (json.unpaid_orders) {
+
+ var orders = [];
+ var existing = this.get_order_list();
+ var existing_uids = {};
+ var skipped_sessions = {};
+
+ for (var i = 0; i < existing.length; i++) {
+ existing_uids[existing[i].uid] = true;
+ }
+
+ for (var i = 0; i < json.unpaid_orders.length; i++) {
+ var order = json.unpaid_orders[i];
+ if (order.pos_session_id !== this.pos_session.id) {
+ report.unpaid_skipped_session += 1;
+ skipped_sessions[order.pos_session_id] = true;
+ } else if (existing_uids[order.uid]) {
+ report.unpaid_skipped_existing += 1;
+ } else {
+ orders.push(new exports.Order({},{
+ pos: this,
+ json: order,
+ }));
+ }
+ }
+
+ orders = orders.sort(function(a,b){
+ return a.sequence_number - b.sequence_number;
+ });
+
+ if (orders.length) {
+ report.unpaid = orders.length;
+ this.get('orders').add(orders);
+ }
+
+ report.unpaid_skipped_sessions = _.keys(skipped_sessions);
+ }
+
+ return report;
+ },
+
+ _load_orders: function(){
+ var jsons = this.db.get_unpaid_orders();
+ var orders = [];
+ var not_loaded_count = 0;
+
+ for (var i = 0; i < jsons.length; i++) {
+ var json = jsons[i];
+ if (json.pos_session_id === this.pos_session.id) {
+ orders.push(new exports.Order({},{
+ pos: this,
+ json: json,
+ }));
+ } else {
+ not_loaded_count += 1;
+ }
+ }
+
+ if (not_loaded_count) {
+ console.info('There are '+not_loaded_count+' locally saved unpaid orders belonging to another session');
+ }
+
+ orders = orders.sort(function(a,b){
+ return a.sequence_number - b.sequence_number;
+ });
+
+ if (orders.length) {
+ this.get('orders').add(orders);
+ }
+ },
+
+ /**
+ * Directly calls the requested service, instead of triggering a
+ * 'call_service' event up, which wouldn't work as services have no parent
+ *
+ * @param {OdooEvent} ev
+ */
+ _trigger_up: function (ev) {
+ if (ev.is_stopped()) {
+ return;
+ }
+ const payload = ev.data;
+ if (ev.name === 'call_service') {
+ let args = payload.args || [];
+ if (payload.service === 'ajax' && payload.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(ev.target);
+ }
+ const service = this.env.services[payload.service];
+ const result = service[payload.method].apply(service, args);
+ payload.callback(result);
+ }
+ },
+
+ 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))}`;
+ },
+
+ disallowLineQuantityChange() {
+ return false;
+ },
+
+ getCurrencySymbol() {
+ return this.currency ? this.currency.symbol : '$';
+ },
+});
+
+/**
+ * Call this function to map your PaymentInterface implementation to
+ * the use_payment_terminal field. When the POS loads it will take
+ * care of instantiating your interface and setting it on the right
+ * payment methods.
+ *
+ * @param {string} use_payment_terminal - value used in the
+ * use_payment_terminal selection field
+ *
+ * @param {Object} ImplementedPaymentInterface - implemented
+ * PaymentInterface
+ */
+exports.register_payment_method = function(use_payment_terminal, ImplementedPaymentInterface) {
+ exports.PosModel.prototype.electronic_payment_interfaces[use_payment_terminal] = ImplementedPaymentInterface;
+};
+
+// Add fields to the list of read fields when a model is loaded
+// by the point of sale.
+// e.g: module.load_fields("product.product",['price','category'])
+
+exports.load_fields = function(model_name, fields) {
+ if (!(fields instanceof Array)) {
+ fields = [fields];
+ }
+
+ var models = exports.PosModel.prototype.models;
+ for (var i = 0; i < models.length; i++) {
+ var model = models[i];
+ if (model.model === model_name) {
+ // if 'fields' is empty all fields are loaded, so we do not need
+ // to modify the array
+ if ((model.fields instanceof Array) && model.fields.length > 0) {
+ model.fields = model.fields.concat(fields || []);
+ }
+ }
+ }
+};
+
+// Loads openerp models at the point of sale startup.
+// load_models take an array of model loader declarations.
+// - The models will be loaded in the array order.
+// - If no openerp model name is provided, no server data
+// will be loaded, but the system can be used to preprocess
+// data before load.
+// - loader arguments can be functions that return a dynamic
+// value. The function takes the PosModel as the first argument
+// and a temporary object that is shared by all models, and can
+// be used to store transient information between model loads.
+// - There is no dependency management. The models must be loaded
+// in the right order. Newly added models are loaded at the end
+// but the after / before options can be used to load directly
+// before / after another model.
+//
+// models: [{
+// model: [string] the name of the openerp model to load.
+// label: [string] The label displayed during load.
+// fields: [[string]|function] the list of fields to be loaded.
+// Empty Array / Null loads all fields.
+// order: [[string]|function] the models will be ordered by
+// the provided fields
+// domain: [domain|function] the domain that determines what
+// models need to be loaded. Null loads everything
+// ids: [[id]|function] the id list of the models that must
+// be loaded. Overrides domain.
+// context: [Dict|function] the openerp context for the model read
+// condition: [function] do not load the models if it evaluates to
+// false.
+// loaded: [function(self,model)] this function is called once the
+// models have been loaded, with the data as second argument
+// if the function returns a promise, the next model will
+// wait until it resolves before loading.
+// }]
+//
+// options:
+// before: [string] The model will be loaded before the named models
+// (applies to both model name and label)
+// after: [string] The model will be loaded after the (last loaded)
+// named model. (applies to both model name and label)
+//
+exports.load_models = function(models,options) {
+ options = options || {};
+ if (!(models instanceof Array)) {
+ models = [models];
+ }
+
+ var pmodels = exports.PosModel.prototype.models;
+ var index = pmodels.length;
+ if (options.before) {
+ for (var i = 0; i < pmodels.length; i++) {
+ if ( pmodels[i].model === options.before ||
+ pmodels[i].label === options.before ){
+ index = i;
+ break;
+ }
+ }
+ } else if (options.after) {
+ for (var i = 0; i < pmodels.length; i++) {
+ if ( pmodels[i].model === options.after ||
+ pmodels[i].label === options.after ){
+ index = i + 1;
+ }
+ }
+ }
+ pmodels.splice.apply(pmodels,[index,0].concat(models));
+};
+
+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
+ // the default UOM set on the product and the user cannot change
+ // it.
+ //
+ // Pricelist items do not have to be sorted. All
+ // product.pricelist.item records are loaded with a search_read
+ // and were automatically sorted based on their _order by the
+ // ORM. After that they are added in this order to the pricelists.
+ get_price: function(pricelist, quantity, price_extra){
+ var self = this;
+ var date = moment().startOf('day');
+
+ // In case of nested pricelists, it is necessary that all pricelists are made available in
+ // the POS. Display a basic alert to the user in this case.
+ if (pricelist === undefined) {
+ alert(_t(
+ 'An error occurred when loading product prices. ' +
+ 'Make sure all pricelists are available in the POS.'
+ ));
+ }
+
+ var category_ids = [];
+ var category = this.categ;
+ while (category) {
+ category_ids.push(category.id);
+ category = category.parent;
+ }
+
+ var pricelist_items = _.filter(pricelist.items, function (item) {
+ return (! item.product_tmpl_id || item.product_tmpl_id[0] === self.product_tmpl_id) &&
+ (! item.product_id || item.product_id[0] === self.id) &&
+ (! item.categ_id || _.contains(category_ids, item.categ_id[0])) &&
+ (! item.date_start || moment(item.date_start).isSameOrBefore(date)) &&
+ (! item.date_end || moment(item.date_end).isSameOrAfter(date));
+ });
+
+ var price = self.lst_price;
+ if (price_extra){
+ price += price_extra;
+ }
+ _.find(pricelist_items, function (rule) {
+ if (rule.min_quantity && quantity < rule.min_quantity) {
+ return false;
+ }
+
+ if (rule.base === 'pricelist') {
+ price = self.get_price(rule.base_pricelist, quantity);
+ } else if (rule.base === 'standard_price') {
+ price = self.standard_price;
+ }
+
+ if (rule.compute_price === 'fixed') {
+ price = rule.fixed_price;
+ return true;
+ } else if (rule.compute_price === 'percentage') {
+ price = price - (price * (rule.percent_price / 100));
+ return true;
+ } else {
+ var price_limit = price;
+ price = price - (price * (rule.price_discount / 100));
+ if (rule.price_round) {
+ price = round_pr(price, rule.price_round);
+ }
+ if (rule.price_surcharge) {
+ price += rule.price_surcharge;
+ }
+ if (rule.price_min_margin) {
+ price = Math.max(price, price_limit + rule.price_min_margin);
+ }
+ if (rule.price_max_margin) {
+ price = Math.min(price, price_limit + rule.price_max_margin);
+ }
+ return true;
+ }
+
+ return false;
+ });
+
+ // This return value has to be rounded with round_di before
+ // being used further. Note that this cannot happen here,
+ // because it would cause inconsistencies with the backend for
+ // pricelist that have base == 'pricelist'.
+ return price;
+ },
+});
+
+var orderline_id = 1;
+
+// An orderline represent one element of the content of a client's shopping cart.
+// An orderline contains a product, its quantity, its price, discount. etc.
+// An Order contains zero or more Orderlines.
+exports.Orderline = Backbone.Model.extend({
+ initialize: function(attr,options){
+ this.pos = options.pos;
+ this.order = options.order;
+ if (options.json) {
+ try {
+ this.init_from_JSON(options.json);
+ } catch(error) {
+ console.error('ERROR: attempting to recover product ID', options.json.product_id,
+ 'not available in the point of sale. Correct the product or clean the browser cache.');
+ }
+ return;
+ }
+ this.product = options.product;
+ this.set_product_lot(this.product);
+ this.set_quantity(1);
+ this.discount = 0;
+ this.discountStr = '0';
+ this.selected = false;
+ this.description = '';
+ this.price_extra = 0;
+ this.full_product_name = '';
+ this.id = orderline_id++;
+ this.price_manually_set = false;
+
+ if (options.price) {
+ this.set_unit_price(options.price);
+ } else {
+ this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity()));
+ }
+ },
+ init_from_JSON: function(json) {
+ this.product = this.pos.db.get_product_by_id(json.product_id);
+ this.set_product_lot(this.product);
+ this.price = json.price_unit;
+ this.set_discount(json.discount);
+ this.set_quantity(json.qty, 'do not recompute unit price');
+ this.set_description(json.description);
+ this.set_price_extra(json.price_extra);
+ this.set_full_product_name(json.full_product_name);
+ this.id = json.id ? json.id : orderline_id++;
+ orderline_id = Math.max(this.id+1,orderline_id);
+ var pack_lot_lines = json.pack_lot_ids;
+ for (var i = 0; i < pack_lot_lines.length; i++) {
+ var packlotline = pack_lot_lines[i][2];
+ var pack_lot_line = new exports.Packlotline({}, {'json': _.extend(packlotline, {'order_line':this})});
+ this.pack_lot_lines.add(pack_lot_line);
+ }
+ },
+ clone: function(){
+ var orderline = new exports.Orderline({},{
+ pos: this.pos,
+ order: this.order,
+ product: this.product,
+ price: this.price,
+ });
+ orderline.order = null;
+ orderline.quantity = this.quantity;
+ orderline.quantityStr = this.quantityStr;
+ orderline.discount = this.discount;
+ orderline.price = this.price;
+ orderline.selected = false;
+ 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});
+ },
+ // sets a discount [0,100]%
+ set_discount: function(discount){
+ var parsed_discount = isNaN(parseFloat(discount)) ? 0 : field_utils.parse.float('' + discount);
+ var disc = Math.min(Math.max(parsed_discount || 0, 0),100);
+ this.discount = disc;
+ this.discountStr = '' + disc;
+ this.trigger('change',this);
+ },
+ // returns the discount [0,100]%
+ get_discount: function(){
+ return this.discount;
+ },
+ get_discount_str: function(){
+ return this.discountStr;
+ },
+ set_description: function(description){
+ this.description = description || '';
+ },
+ set_price_extra: function(price_extra){
+ this.price_extra = parseFloat(price_extra) || 0.0;
+ },
+ set_full_product_name: function(full_product_name){
+ this.full_product_name = full_product_name || '';
+ },
+ get_price_extra: function () {
+ return this.price_extra;
+ },
+ // sets the quantity of the product. The quantity will be rounded according to the
+ // product's unity of measure properties. Quantities greater than zero will not get
+ // rounded to zero
+ set_quantity: function(quantity, keep_price){
+ this.order.assert_editable();
+ if(quantity === 'remove'){
+ this.order.remove_orderline(this);
+ return;
+ }else{
+ var quant = typeof(quantity) === 'number' ? quantity : (field_utils.parse.float('' + quantity) || 0);
+ var unit = this.get_unit();
+ if(unit){
+ if (unit.rounding) {
+ var decimals = this.pos.dp['Product Unit of Measure'];
+ var rounding = Math.max(unit.rounding, Math.pow(10, -decimals));
+ this.quantity = round_pr(quant, rounding);
+ this.quantityStr = field_utils.format.float(this.quantity, {digits: [69, decimals]});
+ } else {
+ this.quantity = round_pr(quant, 1);
+ this.quantityStr = this.quantity.toFixed(0);
+ }
+ }else{
+ this.quantity = quant;
+ this.quantityStr = '' + this.quantity;
+ }
+ }
+
+ // just like in sale.order changing the quantity will recompute the unit price
+ if(! keep_price && ! this.price_manually_set){
+ this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity(), this.get_price_extra()));
+ this.order.fix_tax_included_price(this);
+ }
+ this.trigger('change', this);
+ },
+ // return the quantity of product
+ get_quantity: function(){
+ return this.quantity;
+ },
+ get_quantity_str: function(){
+ return this.quantityStr;
+ },
+ get_quantity_str_with_unit: function(){
+ var unit = this.get_unit();
+ if(unit && !unit.is_pos_groupable){
+ return this.quantityStr + ' ' + unit.name;
+ }else{
+ return this.quantityStr;
+ }
+ },
+
+ get_lot_lines: function() {
+ return this.pack_lot_lines.models;
+ },
+
+ get_required_number_of_lots: function(){
+ var lots_required = 1;
+
+ if (this.product.tracking == 'serial') {
+ lots_required = Math.abs(this.quantity);
+ }
+
+ return lots_required;
+ },
+
+ has_valid_product_lot: function(){
+ if(!this.has_product_lot){
+ return true;
+ }
+ var valid_product_lot = this.pack_lot_lines.get_valid_lots();
+ return this.get_required_number_of_lots() === valid_product_lot.length;
+ },
+
+ // return the unit of measure of the product
+ get_unit: function(){
+ return this.product.get_unit();
+ },
+ // return the product of this orderline
+ get_product: function(){
+ return this.product;
+ },
+ get_full_product_name: function () {
+ if (this.full_product_name) {
+ return this.full_product_name
+ }
+ var full_name = this.product.display_name;
+ if (this.description) {
+ full_name += ` (${this.description})`;
+ }
+ return full_name;
+ },
+ // selects or deselects this orderline
+ set_selected: function(selected){
+ this.selected = selected;
+ // this trigger also triggers the change event of the collection.
+ this.trigger('change',this);
+ this.trigger('new-orderline-selected');
+ },
+ // returns true if this orderline is selected
+ is_selected: function(){
+ return this.selected;
+ },
+ // when we add an new orderline we want to merge it with the last line to see reduce the number of items
+ // in the orderline. This returns true if it makes sense to merge the two
+ can_be_merged_with: function(orderline){
+ var price = parseFloat(round_di(this.price || 0, this.pos.dp['Product Price']).toFixed(this.pos.dp['Product Price']));
+ var order_line_price = orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity());
+ order_line_price = orderline.compute_fixed_price(order_line_price);
+ if( this.get_product().id !== orderline.get_product().id){ //only orderline of the same product can be merged
+ return false;
+ }else if(!this.get_unit() || !this.get_unit().is_pos_groupable){
+ return false;
+ }else if(this.get_discount() > 0){ // we don't merge discounted orderlines
+ return false;
+ }else if(!utils.float_is_zero(price - order_line_price - orderline.get_price_extra(),
+ this.pos.currency.decimals)){
+ return false;
+ }else if(this.product.tracking == 'lot' && (this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)) {
+ return false;
+ }else if (this.description !== orderline.description) {
+ return false;
+ }else{
+ return true;
+ }
+ },
+ merge: function(orderline){
+ this.order.assert_editable();
+ this.set_quantity(this.get_quantity() + orderline.get_quantity());
+ },
+ export_as_JSON: function() {
+ var pack_lot_ids = [];
+ if (this.has_product_lot){
+ this.pack_lot_lines.each(_.bind( function(item) {
+ return pack_lot_ids.push([0, 0, item.export_as_JSON()]);
+ }, this));
+ }
+ return {
+ qty: this.get_quantity(),
+ price_unit: this.get_unit_price(),
+ price_subtotal: this.get_price_without_tax(),
+ price_subtotal_incl: this.get_price_with_tax(),
+ discount: this.get_discount(),
+ product_id: this.get_product().id,
+ tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function(tax){ return tax.id; })]],
+ id: this.id,
+ pack_lot_ids: pack_lot_ids,
+ description: this.description,
+ full_product_name: this.get_full_product_name(),
+ price_extra: this.get_price_extra(),
+ };
+ },
+ //used to create a json of the ticket, to be sent to the printer
+ export_for_printing: function(){
+ return {
+ id: this.id,
+ quantity: this.get_quantity(),
+ unit_name: this.get_unit().name,
+ is_in_unit: this.get_unit().id == this.pos.uom_unit_id,
+ price: this.get_unit_display_price(),
+ discount: this.get_discount(),
+ product_name: this.get_product().display_name,
+ product_name_wrapped: this.generate_wrapped_product_name(),
+ price_lst: this.get_lst_price(),
+ display_discount_policy: this.display_discount_policy(),
+ price_display_one: this.get_display_price_one(),
+ price_display : this.get_display_price(),
+ price_with_tax : this.get_price_with_tax(),
+ price_without_tax: this.get_price_without_tax(),
+ price_with_tax_before_discount: this.get_price_with_tax_before_discount(),
+ tax: this.get_tax(),
+ product_description: this.get_product().description,
+ product_description_sale: this.get_product().description_sale,
+ pack_lot_lines: this.get_lot_lines()
+ };
+ },
+ generate_wrapped_product_name: function() {
+ var MAX_LENGTH = 24; // 40 * line ratio of .6
+ var wrapped = [];
+ var name = this.get_full_product_name();
+ var current_line = "";
+
+ while (name.length > 0) {
+ var space_index = name.indexOf(" ");
+
+ if (space_index === -1) {
+ space_index = name.length;
+ }
+
+ if (current_line.length + space_index > MAX_LENGTH) {
+ if (current_line.length) {
+ wrapped.push(current_line);
+ }
+ current_line = "";
+ }
+
+ current_line += name.slice(0, space_index + 1);
+ name = name.slice(space_index + 1);
+ }
+
+ if (current_line.length) {
+ wrapped.push(current_line);
+ }
+
+ return wrapped;
+ },
+ // changes the base price of the product for this orderline
+ set_unit_price: function(price){
+ this.order.assert_editable();
+ var parsed_price = !isNaN(price) ?
+ price :
+ isNaN(parseFloat(price)) ? 0 : field_utils.parse.float('' + price)
+ this.price = round_di(parsed_price || 0, this.pos.dp['Product Price']);
+ this.trigger('change',this);
+ },
+ get_unit_price: function(){
+ var digits = this.pos.dp['Product Price'];
+ // round and truncate to mimic _symbol_set behavior
+ return parseFloat(round_di(this.price || 0, digits).toFixed(digits));
+ },
+ get_unit_display_price: function(){
+ if (this.pos.config.iface_tax_included === 'total') {
+ var quantity = this.quantity;
+ this.quantity = 1.0;
+ var price = this.get_all_prices().priceWithTax;
+ this.quantity = quantity;
+ return price;
+ } else {
+ return this.get_unit_price();
+ }
+ },
+ get_base_price: function(){
+ var rounding = this.pos.currency.rounding;
+ return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount()/100), rounding);
+ },
+ get_display_price_one: function(){
+ var rounding = this.pos.currency.rounding;
+ var price_unit = this.get_unit_price();
+ if (this.pos.config.iface_tax_included !== 'total') {
+ return round_pr(price_unit * (1.0 - (this.get_discount() / 100.0)), rounding);
+ } else {
+ var product = this.get_product();
+ var taxes_ids = product.taxes_id;
+ var taxes = this.pos.taxes;
+ var product_taxes = [];
+
+ _(taxes_ids).each(function(el){
+ product_taxes.push(_.detect(taxes, function(t){
+ return t.id === el;
+ }));
+ });
+
+ var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding);
+
+ return round_pr(all_taxes.total_included * (1 - this.get_discount()/100), rounding);
+ }
+ },
+ get_display_price: function(){
+ if (this.pos.config.iface_tax_included === 'total') {
+ return this.get_price_with_tax();
+ } else {
+ return this.get_base_price();
+ }
+ },
+ get_price_without_tax: function(){
+ return this.get_all_prices().priceWithoutTax;
+ },
+ get_price_with_tax: function(){
+ return this.get_all_prices().priceWithTax;
+ },
+ get_price_with_tax_before_discount: function () {
+ return this.get_all_prices().priceWithTaxBeforeDiscount;
+ },
+ get_tax: function(){
+ return this.get_all_prices().tax;
+ },
+ get_applicable_taxes: function(){
+ var i;
+ // Shenaningans because we need
+ // to keep the taxes ordering.
+ var ptaxes_ids = this.get_product().taxes_id;
+ var ptaxes_set = {};
+ for (i = 0; i < ptaxes_ids.length; i++) {
+ ptaxes_set[ptaxes_ids[i]] = true;
+ }
+ var taxes = [];
+ for (i = 0; i < this.pos.taxes.length; i++) {
+ if (ptaxes_set[this.pos.taxes[i].id]) {
+ taxes.push(this.pos.taxes[i]);
+ }
+ }
+ return taxes;
+ },
+ get_tax_details: function(){
+ return this.get_all_prices().taxDetails;
+ },
+ get_taxes: function(){
+ var taxes_ids = this.get_product().taxes_id;
+ var taxes = [];
+ for (var i = 0; i < taxes_ids.length; i++) {
+ if (this.pos.taxes_by_id[taxes_ids[i]]) {
+ taxes.push(this.pos.taxes_by_id[taxes_ids[i]]);
+ }
+ }
+ return taxes;
+ },
+ _map_tax_fiscal_position: function(tax, order = false) {
+ var self = this;
+ var current_order = order || this.pos.get_order();
+ var order_fiscal_position = current_order && current_order.fiscal_position;
+ var taxes = [];
+
+ if (order_fiscal_position) {
+ var tax_mappings = _.filter(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) {
+ return fiscal_position_tax.tax_src_id[0] === tax.id;
+ });
+
+ if (tax_mappings && tax_mappings.length) {
+ _.each(tax_mappings, function(tm) {
+ if (tm.tax_dest_id) {
+ taxes.push(self.pos.taxes_by_id[tm.tax_dest_id[0]]);
+ }
+ });
+ } else{
+ taxes.push(tax);
+ }
+ } else {
+ taxes.push(tax);
+ }
+
+ return taxes;
+ },
+ /**
+ * Mirror JS method of:
+ * _compute_amount in addons/account/models/account.py
+ */
+ _compute_all: function(tax, base_amount, quantity, price_exclude) {
+ if(price_exclude === undefined)
+ var price_include = tax.price_include;
+ else
+ var price_include = !price_exclude;
+ if (tax.amount_type === 'fixed') {
+ var sign_base_amount = Math.sign(base_amount) || 1;
+ // Since base amount has been computed with quantity
+ // we take the abs of quantity
+ // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce
+ return tax.amount * sign_base_amount * Math.abs(quantity);
+ }
+ if (tax.amount_type === 'percent' && !price_include){
+ return base_amount * tax.amount / 100;
+ }
+ if (tax.amount_type === 'percent' && price_include){
+ return base_amount - (base_amount / (1 + tax.amount / 100));
+ }
+ if (tax.amount_type === 'division' && !price_include) {
+ return base_amount / (1 - tax.amount / 100) - base_amount;
+ }
+ if (tax.amount_type === 'division' && price_include) {
+ return base_amount - (base_amount * (tax.amount / 100));
+ }
+ return false;
+ },
+ /**
+ * Mirror JS method of:
+ * compute_all in addons/account/models/account.py
+ *
+ * Read comments in the python side method for more details about each sub-methods.
+ */
+ compute_all: function(taxes, price_unit, quantity, currency_rounding, handle_price_include=true) {
+ var self = this;
+
+ // 1) Flatten the taxes.
+
+ var _collect_taxes = function(taxes, all_taxes){
+ taxes.sort(function (tax1, tax2) {
+ return tax1.sequence - tax2.sequence;
+ });
+ _(taxes).each(function(tax){
+ if(tax.amount_type === 'group')
+ all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes);
+ else
+ all_taxes.push(tax);
+ });
+ return all_taxes;
+ }
+ var collect_taxes = function(taxes){
+ return _collect_taxes(taxes, []);
+ }
+
+ taxes = collect_taxes(taxes);
+
+ // 2) Deal with the rounding methods
+
+ var round_tax = this.pos.company.tax_calculation_rounding_method != 'round_globally';
+
+ var initial_currency_rounding = currency_rounding;
+ if(!round_tax)
+ currency_rounding = currency_rounding * 0.00001;
+
+ // 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
+ var recompute_base = function(base_amount, fixed_amount, percent_amount, division_amount){
+ return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100;
+ }
+
+ var base = round_pr(price_unit * quantity, initial_currency_rounding);
+
+ var sign = 1;
+ if(base < 0){
+ base = -base;
+ sign = -1;
+ }
+
+ var total_included_checkpoints = {};
+ var i = taxes.length - 1;
+ var store_included_tax_total = true;
+
+ var incl_fixed_amount = 0.0;
+ var incl_percent_amount = 0.0;
+ var incl_division_amount = 0.0;
+
+ var cached_tax_amounts = {};
+ if (handle_price_include){
+ _(taxes.reverse()).each(function(tax){
+ if(tax.include_base_amount){
+ base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount);
+ incl_fixed_amount = 0.0;
+ incl_percent_amount = 0.0;
+ incl_division_amount = 0.0;
+ store_included_tax_total = true;
+ }
+ if(tax.price_include){
+ if(tax.amount_type === 'percent')
+ incl_percent_amount += tax.amount;
+ else if(tax.amount_type === 'division')
+ incl_division_amount += tax.amount;
+ else if(tax.amount_type === 'fixed')
+ incl_fixed_amount += Math.abs(quantity) * tax.amount
+ else{
+ var tax_amount = self._compute_all(tax, base, quantity);
+ incl_fixed_amount += tax_amount;
+ cached_tax_amounts[i] = tax_amount;
+ }
+ if(store_included_tax_total){
+ total_included_checkpoints[i] = base;
+ store_included_tax_total = false;
+ }
+ }
+ i -= 1;
+ });
+ }
+
+ var total_excluded = round_pr(recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount), initial_currency_rounding);
+ var total_included = total_excluded;
+
+ // 4) Iterate the taxes in the sequence order to fill missing base/amount values.
+
+ base = total_excluded;
+
+ var skip_checkpoint = false;
+
+ var taxes_vals = [];
+ i = 0;
+ var cumulated_tax_included_amount = 0;
+ _(taxes.reverse()).each(function(tax){
+ if(!skip_checkpoint && tax.price_include && total_included_checkpoints[i] !== undefined){
+ var tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount);
+ cumulated_tax_included_amount = 0;
+ }else
+ var tax_amount = self._compute_all(tax, base, quantity, true);
+
+ tax_amount = round_pr(tax_amount, currency_rounding);
+
+ if(tax.price_include && total_included_checkpoints[i] === undefined)
+ cumulated_tax_included_amount += tax_amount;
+
+ taxes_vals.push({
+ 'id': tax.id,
+ 'name': tax.name,
+ 'amount': sign * tax_amount,
+ 'base': sign * round_pr(base, currency_rounding),
+ });
+
+ if(tax.include_base_amount){
+ base += tax_amount;
+ if(!tax.price_include)
+ skip_checkpoint = true;
+ }
+
+ total_included += tax_amount;
+ i += 1;
+ });
+
+ return {
+ 'taxes': taxes_vals,
+ 'total_excluded': sign * round_pr(total_excluded, this.pos.currency.rounding),
+ 'total_included': sign * round_pr(total_included, this.pos.currency.rounding),
+ }
+ },
+ get_all_prices: function(){
+ var self = this;
+
+ var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0));
+ var taxtotal = 0;
+
+ var product = this.get_product();
+ var taxes = this.pos.taxes;
+ var taxes_ids = _.filter(product.taxes_id, t => t in this.pos.taxes_by_id);
+ var taxdetail = {};
+ var product_taxes = [];
+
+ _(taxes_ids).each(function(el){
+ var tax = _.detect(taxes, function(t){
+ return t.id === el;
+ });
+ product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax, self.order));
+ });
+ product_taxes = _.uniq(product_taxes, function(tax) { return tax.id; });
+
+ var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding);
+ var all_taxes_before_discount = this.compute_all(product_taxes, this.get_unit_price(), this.get_quantity(), this.pos.currency.rounding);
+ _(all_taxes.taxes).each(function(tax) {
+ taxtotal += tax.amount;
+ taxdetail[tax.id] = tax.amount;
+ });
+
+ return {
+ "priceWithTax": all_taxes.total_included,
+ "priceWithoutTax": all_taxes.total_excluded,
+ "priceSumTaxVoid": all_taxes.total_void,
+ "priceWithTaxBeforeDiscount": all_taxes_before_discount.total_included,
+ "tax": taxtotal,
+ "taxDetails": taxdetail,
+ };
+ },
+ display_discount_policy: function(){
+ return this.order.pricelist.discount_policy;
+ },
+ compute_fixed_price: function (price) {
+ var order = this.order;
+ if(order.fiscal_position) {
+ var taxes = this.get_taxes();
+ var mapped_included_taxes = [];
+ var new_included_taxes = [];
+ var self = this;
+ _(taxes).each(function(tax) {
+ var line_taxes = self._map_tax_fiscal_position(tax, order);
+ if (line_taxes.length && line_taxes[0].price_include){
+ new_included_taxes = new_included_taxes.concat(line_taxes);
+ }
+ if(tax.price_include && !_.contains(line_taxes, tax)){
+ mapped_included_taxes.push(tax);
+ }
+ });
+
+ if (mapped_included_taxes.length > 0) {
+ if (new_included_taxes.length > 0) {
+ var price_without_taxes = this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded
+ return this.compute_all(new_included_taxes, price_without_taxes, 1, order.pos.currency.rounding, false).total_included
+ }
+ else{
+ return this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded;
+ }
+ }
+ }
+ return price;
+ },
+ get_fixed_lst_price: function(){
+ return this.compute_fixed_price(this.get_lst_price());
+ },
+ get_lst_price: function(){
+ return this.product.lst_price;
+ },
+ set_lst_price: function(price){
+ this.order.assert_editable();
+ this.product.lst_price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']);
+ this.trigger('change',this);
+ },
+ is_last_line: function() {
+ var order = this.pos.get_order();
+ var last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length-1];
+ var selectedLine = order? order.selected_orderline: null;
+
+ return !selectedLine ? false : last_id === selectedLine.cid;
+ },
+});
+
+var OrderlineCollection = Backbone.Collection.extend({
+ model: exports.Orderline,
+});
+
+exports.Packlotline = Backbone.Model.extend({
+ defaults: {
+ lot_name: null
+ },
+ initialize: function(attributes, options){
+ this.order_line = options.order_line;
+ if (options.json) {
+ this.init_from_JSON(options.json);
+ return;
+ }
+ },
+
+ init_from_JSON: function(json) {
+ this.order_line = json.order_line;
+ this.set_lot_name(json.lot_name);
+ },
+
+ set_lot_name: function(name){
+ this.set({lot_name : _.str.trim(name) || null});
+ },
+
+ get_lot_name: function(){
+ return this.get('lot_name');
+ },
+
+ export_as_JSON: function(){
+ return {
+ lot_name: this.get_lot_name(),
+ };
+ },
+
+ add: function(){
+ var order_line = this.order_line,
+ index = this.collection.indexOf(this);
+ var new_lot_model = new exports.Packlotline({}, {'order_line': this.order_line});
+ this.collection.add(new_lot_model, {at: index + 1});
+ return new_lot_model;
+ },
+
+ remove: function(){
+ this.collection.remove(this);
+ }
+});
+
+var PacklotlineCollection = Backbone.Collection.extend({
+ model: exports.Packlotline,
+ initialize: function(models, options) {
+ this.order_line = options.order_line;
+ },
+
+ get_valid_lots: function(){
+ return this.filter(function(model){
+ return model.get('lot_name');
+ });
+ },
+
+ set_quantity_by_lot: function() {
+ var valid_lots_quantity = this.get_valid_lots().length;
+ if (this.order_line.quantity < 0){
+ valid_lots_quantity = -valid_lots_quantity;
+ }
+ this.order_line.set_quantity(valid_lots_quantity);
+ }
+});
+
+// Every Paymentline contains a cashregister and an amount of money.
+exports.Paymentline = Backbone.Model.extend({
+ initialize: function(attributes, options) {
+ this.pos = options.pos;
+ this.order = options.order;
+ this.amount = 0;
+ this.selected = false;
+ this.cashier_receipt = '';
+ this.ticket = '';
+ this.payment_status = '';
+ this.card_type = '';
+ this.cardholder_name = '';
+ this.transaction_id = '';
+
+ if (options.json) {
+ this.init_from_JSON(options.json);
+ return;
+ }
+ this.payment_method = options.payment_method;
+ if (this.payment_method === undefined) {
+ throw new Error(_t('Please configure a payment method in your POS.'));
+ }
+ this.name = this.payment_method.name;
+ },
+ init_from_JSON: function(json){
+ this.amount = json.amount;
+ this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id];
+ this.name = this.payment_method.name;
+ this.payment_status = json.payment_status;
+ this.ticket = json.ticket;
+ this.card_type = json.card_type;
+ this.cardholder_name = json.cardholder_name;
+ this.transaction_id = json.transaction_id;
+ this.is_change = json.is_change;
+ },
+ //sets the amount of money on this payment line
+ set_amount: function(value){
+ this.order.assert_editable();
+ this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals);
+ if (this.pos.config.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display();
+ this.trigger('change',this);
+ },
+ // returns the amount of money on this paymentline
+ get_amount: function(){
+ return this.amount;
+ },
+ get_amount_str: function(){
+ return field_utils.format.float(this.amount, {digits: [69, this.pos.currency.decimals]});
+ },
+ set_selected: function(selected){
+ if(this.selected !== selected){
+ this.selected = selected;
+ this.trigger('change',this);
+ }
+ },
+ /**
+ * returns {string} payment status.
+ */
+ get_payment_status: function() {
+ return this.payment_status;
+ },
+
+ /**
+ * Set the new payment status.
+ *
+ * @param {string} value - new status.
+ */
+ set_payment_status: function(value) {
+ this.payment_status = value;
+ this.trigger('change', this);
+ },
+
+ /**
+ * Check if paymentline is done.
+ * Paymentline is done if there is no payment status or the payment status is done.
+ */
+ is_done: function() {
+ return this.get_payment_status() ? this.get_payment_status() === 'done' || this.get_payment_status() === 'reversed': true;
+ },
+
+ /**
+ * Set info to be printed on the cashier receipt. value should
+ * be compatible with both the QWeb and ESC/POS receipts.
+ *
+ * @param {string} value - receipt info
+ */
+ set_cashier_receipt: function (value) {
+ this.cashier_receipt = value;
+ this.trigger('change', this);
+ },
+
+ /**
+ * Set additional info to be printed on the receipts. value should
+ * be compatible with both the QWeb and ESC/POS receipts.
+ *
+ * @param {string} value - receipt info
+ */
+ set_receipt_info: function(value) {
+ this.ticket += value;
+ this.trigger('change', this);
+ },
+
+ // returns the associated cashregister
+ //exports as JSON for server communication
+ export_as_JSON: function(){
+ return {
+ name: time.datetime_to_str(new Date()),
+ payment_method_id: this.payment_method.id,
+ amount: this.get_amount(),
+ payment_status: this.payment_status,
+ ticket: this.ticket,
+ card_type: this.card_type,
+ cardholder_name: this.cardholder_name,
+ transaction_id: this.transaction_id,
+ };
+ },
+ //exports as JSON for receipt printing
+ export_for_printing: function(){
+ return {
+ cid: this.cid,
+ amount: this.get_amount(),
+ name: this.name,
+ ticket: this.ticket,
+ };
+ },
+ // If payment status is a non-empty string, then it is an electronic payment.
+ // TODO: There has to be a less confusing way to distinguish simple payments
+ // from electronic transactions. Perhaps use a flag?
+ is_electronic: function() {
+ return Boolean(this.get_payment_status());
+ },
+});
+
+var PaymentlineCollection = Backbone.Collection.extend({
+ model: exports.Paymentline,
+});
+
+// An order more or less represents the content of a client's shopping cart (the OrderLines)
+// plus the associated payment information (the Paymentlines)
+// there is always an active ('selected') order in the Pos, a new one is created
+// automaticaly once an order is completed and sent to the server.
+exports.Order = Backbone.Model.extend({
+ initialize: function(attributes,options){
+ Backbone.Model.prototype.initialize.apply(this, arguments);
+ var self = this;
+ options = options || {};
+
+ this.locked = false;
+ this.pos = options.pos;
+ this.selected_orderline = undefined;
+ this.selected_paymentline = undefined;
+ this.screen_data = {}; // see Gui
+ this.temporary = options.temporary || false;
+ this.creation_date = new Date();
+ this.to_invoice = false;
+ this.orderlines = new OrderlineCollection();
+ this.paymentlines = new PaymentlineCollection();
+ this.pos_session_id = this.pos.pos_session.id;
+ this.employee = this.pos.employee;
+ this.finalized = false; // if true, cannot be modified.
+ this.set_pricelist(this.pos.default_pricelist);
+
+ this.set({ client: null });
+
+ this.uiState = {
+ ReceiptScreen: new Context({
+ inputEmail: '',
+ // if null: not yet tried to send
+ // if false/true: tried sending email
+ emailSuccessful: null,
+ emailNotice: '',
+ }),
+ TipScreen: new Context({
+ inputTipAmount: '',
+ })
+ };
+
+ if (options.json) {
+ this.init_from_JSON(options.json);
+ } else {
+ this.sequence_number = this.pos.pos_session.sequence_number++;
+ this.uid = this.generate_unique_id();
+ this.name = _.str.sprintf(_t("Order %s"), this.uid);
+ this.validation_date = undefined;
+ this.fiscal_position = _.find(this.pos.fiscal_positions, function(fp) {
+ return fp.id === self.pos.config.default_fiscal_position_id[0];
+ });
+ }
+
+ this.on('change', function(){ this.save_to_db("order:change"); }, this);
+ this.orderlines.on('change', function(){ this.save_to_db("orderline:change"); }, this);
+ this.orderlines.on('add', function(){ this.save_to_db("orderline:add"); }, this);
+ this.orderlines.on('remove', function(){ this.save_to_db("orderline:remove"); }, this);
+ this.paymentlines.on('change', function(){ this.save_to_db("paymentline:change"); }, this);
+ this.paymentlines.on('add', function(){ this.save_to_db("paymentline:add"); }, this);
+ this.paymentlines.on('remove', function(){ this.save_to_db("paymentline:rem"); }, this);
+
+ if (this.pos.config.iface_customer_facing_display) {
+ this.paymentlines.on('add', this.pos.send_current_order_to_customer_facing_display, this.pos);
+ this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos);
+ }
+
+ this.save_to_db();
+
+ return this;
+ },
+ save_to_db: function(){
+ if (!this.temporary && !this.locked) {
+ this.pos.db.save_unpaid_order(this);
+ }
+ },
+ /**
+ * Initialize PoS order from a JSON string.
+ *
+ * If the order was created in another session, the sequence number should be changed so it doesn't conflict
+ * with orders in the current session.
+ * Else, the sequence number of the session should follow on the sequence number of the loaded order.
+ *
+ * @param {object} json JSON representing one PoS order.
+ */
+ init_from_JSON: function(json) {
+ var client;
+ if (json.pos_session_id !== this.pos.pos_session.id) {
+ this.sequence_number = this.pos.pos_session.sequence_number++;
+ } else {
+ this.sequence_number = json.sequence_number;
+ this.pos.pos_session.sequence_number = Math.max(this.sequence_number+1,this.pos.pos_session.sequence_number);
+ }
+ this.session_id = this.pos.pos_session.id;
+ this.uid = json.uid;
+ this.name = _.str.sprintf(_t("Order %s"), this.uid);
+ this.validation_date = json.creation_date;
+ this.server_id = json.server_id ? json.server_id : false;
+ this.user_id = json.user_id;
+
+ if (json.fiscal_position_id) {
+ var fiscal_position = _.find(this.pos.fiscal_positions, function (fp) {
+ return fp.id === json.fiscal_position_id;
+ });
+
+ if (fiscal_position) {
+ this.fiscal_position = fiscal_position;
+ } else {
+ console.error('ERROR: trying to load a fiscal position not available in the pos');
+ }
+ }
+
+ if (json.pricelist_id) {
+ this.pricelist = _.find(this.pos.pricelists, function (pricelist) {
+ return pricelist.id === json.pricelist_id;
+ });
+ } else {
+ this.pricelist = this.pos.default_pricelist;
+ }
+
+ if (json.partner_id) {
+ client = this.pos.db.get_partner_by_id(json.partner_id);
+ if (!client) {
+ console.error('ERROR: trying to load a partner not available in the pos');
+ }
+ } else {
+ client = null;
+ }
+ this.set_client(client);
+
+ this.temporary = false; // FIXME
+ this.to_invoice = false; // FIXME
+
+ var orderlines = json.lines;
+ for (var i = 0; i < orderlines.length; i++) {
+ var orderline = orderlines[i][2];
+ this.add_orderline(new exports.Orderline({}, {pos: this.pos, order: this, json: orderline}));
+ }
+
+ var paymentlines = json.statement_ids;
+ for (var i = 0; i < paymentlines.length; i++) {
+ var paymentline = paymentlines[i][2];
+ var newpaymentline = new exports.Paymentline({},{pos: this.pos, order: this, json: paymentline});
+ this.paymentlines.add(newpaymentline);
+
+ if (i === paymentlines.length - 1) {
+ this.select_paymentline(newpaymentline);
+ }
+ }
+
+ // Tag this order as 'locked' if it is already paid.
+ this.locked = ['paid', 'done', 'invoiced'].includes(json.state);
+ this.state = json.state;
+ this.amount_return = json.amount_return;
+ this.account_move = json.account_move;
+ this.backendId = json.id;
+ this.isFromClosedSession = json.is_session_closed;
+ this.is_tipped = json.is_tipped || false;
+ this.tip_amount = json.tip_amount || 0;
+ },
+ export_as_JSON: function() {
+ var orderLines, paymentLines;
+ orderLines = [];
+ this.orderlines.each(_.bind( function(item) {
+ return orderLines.push([0, 0, item.export_as_JSON()]);
+ }, this));
+ paymentLines = [];
+ this.paymentlines.each(_.bind( function(item) {
+ return paymentLines.push([0, 0, item.export_as_JSON()]);
+ }, this));
+ var json = {
+ name: this.get_name(),
+ amount_paid: this.get_total_paid() - this.get_change(),
+ amount_total: this.get_total_with_tax(),
+ amount_tax: this.get_total_tax(),
+ amount_return: this.get_change(),
+ lines: orderLines,
+ statement_ids: paymentLines,
+ pos_session_id: this.pos_session_id,
+ pricelist_id: this.pricelist ? this.pricelist.id : false,
+ partner_id: this.get_client() ? this.get_client().id : false,
+ user_id: this.pos.user.id,
+ uid: this.uid,
+ sequence_number: this.sequence_number,
+ creation_date: this.validation_date || this.creation_date, // todo: rename creation_date in master
+ fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false,
+ server_id: this.server_id ? this.server_id : false,
+ to_invoice: this.to_invoice ? this.to_invoice : false,
+ is_tipped: this.is_tipped || false,
+ tip_amount: this.tip_amount || 0,
+ };
+ if (!this.is_paid && this.user_id) {
+ json.user_id = this.user_id;
+ }
+ return json;
+ },
+ export_for_printing: function(){
+ var orderlines = [];
+ var self = this;
+
+ this.orderlines.each(function(orderline){
+ orderlines.push(orderline.export_for_printing());
+ });
+
+ // If order is locked (paid), the 'change' is saved as negative payment,
+ // and is flagged with is_change = true. A receipt that is printed first
+ // time doesn't show this negative payment so we filter it out.
+ var paymentlines = this.paymentlines.models
+ .filter(function (paymentline) {
+ return !paymentline.is_change;
+ })
+ .map(function (paymentline) {
+ return paymentline.export_for_printing();
+ });
+ var client = this.get('client');
+ var cashier = this.pos.get_cashier();
+ var company = this.pos.company;
+ var date = new Date();
+
+ function is_html(subreceipt){
+ return subreceipt ? (subreceipt.split('\n')[0].indexOf('<!DOCTYPE QWEB') >= 0) : false;
+ }
+
+ function render_html(subreceipt){
+ if (!is_html(subreceipt)) {
+ return subreceipt;
+ } else {
+ subreceipt = subreceipt.split('\n').slice(1).join('\n');
+ var qweb = new QWeb2.Engine();
+ qweb.debug = config.isDebug();
+ qweb.default_dict = _.clone(QWeb.default_dict);
+ qweb.add_template('<templates><t t-name="subreceipt">'+subreceipt+'</t></templates>');
+
+ return qweb.render('subreceipt',{'pos':self.pos,'order':self, 'receipt': receipt}) ;
+ }
+ }
+
+ var receipt = {
+ orderlines: orderlines,
+ paymentlines: paymentlines,
+ subtotal: this.get_subtotal(),
+ total_with_tax: this.get_total_with_tax(),
+ total_rounded: this.get_total_with_tax() + this.get_rounding_applied(),
+ total_without_tax: this.get_total_without_tax(),
+ total_tax: this.get_total_tax(),
+ total_paid: this.get_total_paid(),
+ total_discount: this.get_total_discount(),
+ rounding_applied: this.get_rounding_applied(),
+ tax_details: this.get_tax_details(),
+ change: this.locked ? this.amount_return : this.get_change(),
+ name : this.get_name(),
+ client: client ? client : null ,
+ invoice_id: null, //TODO
+ cashier: cashier ? cashier.name : null,
+ precision: {
+ price: 2,
+ money: 2,
+ quantity: 3,
+ },
+ date: {
+ year: date.getFullYear(),
+ month: date.getMonth(),
+ date: date.getDate(), // day of the month
+ day: date.getDay(), // day of the week
+ hour: date.getHours(),
+ minute: date.getMinutes() ,
+ isostring: date.toISOString(),
+ localestring: this.formatted_validation_date,
+ },
+ company:{
+ email: company.email,
+ website: company.website,
+ company_registry: company.company_registry,
+ contact_address: company.partner_id[1],
+ vat: company.vat,
+ vat_label: company.country && company.country.vat_label || _t('Tax ID'),
+ name: company.name,
+ phone: company.phone,
+ logo: this.pos.company_logo_base64,
+ },
+ currency: this.pos.currency,
+ };
+
+ if (is_html(this.pos.config.receipt_header)){
+ receipt.header = '';
+ receipt.header_html = render_html(this.pos.config.receipt_header);
+ } else {
+ receipt.header = this.pos.config.receipt_header || '';
+ }
+
+ if (is_html(this.pos.config.receipt_footer)){
+ receipt.footer = '';
+ receipt.footer_html = render_html(this.pos.config.receipt_footer);
+ } else {
+ receipt.footer = this.pos.config.receipt_footer || '';
+ }
+
+ return receipt;
+ },
+ is_empty: function(){
+ return this.orderlines.models.length === 0;
+ },
+ generate_unique_id: function() {
+ // Generates a public identification number for the order.
+ // The generated number must be unique and sequential. They are made 12 digit long
+ // to fit into EAN-13 barcodes, should it be needed
+
+ function zero_pad(num,size){
+ var s = ""+num;
+ while (s.length < size) {
+ s = "0" + s;
+ }
+ return s;
+ }
+ return zero_pad(this.pos.pos_session.id,5) +'-'+
+ zero_pad(this.pos.pos_session.login_number,3) +'-'+
+ zero_pad(this.sequence_number,4);
+ },
+ get_name: function() {
+ return this.name;
+ },
+ assert_editable: function() {
+ if (this.finalized) {
+ throw new Error('Finalized Order cannot be modified');
+ }
+ },
+ /* ---- Order Lines --- */
+ add_orderline: function(line){
+ this.assert_editable();
+ if(line.order){
+ line.order.remove_orderline(line);
+ }
+ line.order = this;
+ this.orderlines.add(line);
+ this.select_orderline(this.get_last_orderline());
+ },
+ get_orderline: function(id){
+ var orderlines = this.orderlines.models;
+ for(var i = 0; i < orderlines.length; i++){
+ if(orderlines[i].id === id){
+ return orderlines[i];
+ }
+ }
+ return null;
+ },
+ get_orderlines: function(){
+ return this.orderlines.models;
+ },
+ get_last_orderline: function(){
+ return this.orderlines.at(this.orderlines.length -1);
+ },
+ get_tip: function() {
+ var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]);
+ var lines = this.get_orderlines();
+ if (!tip_product) {
+ return 0;
+ } else {
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].get_product() === tip_product) {
+ return lines[i].get_unit_price();
+ }
+ }
+ return 0;
+ }
+ },
+
+ initialize_validation_date: function () {
+ this.validation_date = new Date();
+ this.formatted_validation_date = field_utils.format.datetime(
+ moment(this.validation_date), {}, {timezone: false});
+ },
+
+ set_tip: function(tip) {
+ var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]);
+ var lines = this.get_orderlines();
+ if (tip_product) {
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].get_product() === tip_product) {
+ lines[i].set_unit_price(tip);
+ lines[i].set_lst_price(tip);
+ lines[i].price_manually_set = true;
+ lines[i].order.tip_amount = tip;
+ return;
+ }
+ }
+ return this.add_product(tip_product, {
+ is_tip: true,
+ quantity: 1,
+ price: tip,
+ lst_price: tip,
+ extras: {price_manually_set: true},
+ });
+ }
+ },
+ set_pricelist: function (pricelist) {
+ var self = this;
+ this.pricelist = pricelist;
+
+ var lines_to_recompute = _.filter(this.get_orderlines(), function (line) {
+ return ! line.price_manually_set;
+ });
+ _.each(lines_to_recompute, function (line) {
+ line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity(), line.get_price_extra()));
+ self.fix_tax_included_price(line);
+ });
+ this.trigger('change');
+ },
+ remove_orderline: function( line ){
+ this.assert_editable();
+ this.orderlines.remove(line);
+ this.select_orderline(this.get_last_orderline());
+ },
+
+ fix_tax_included_price: function(line){
+ line.set_unit_price(line.compute_fixed_price(line.price));
+ },
+
+ add_product: function(product, options){
+ if(this._printed){
+ this.destroy();
+ return this.pos.get_order().add_product(product, options);
+ }
+ this.assert_editable();
+ options = options || {};
+ var line = new exports.Orderline({}, {pos: this.pos, order: this, product: product});
+ this.fix_tax_included_price(line);
+
+ if(options.quantity !== undefined){
+ line.set_quantity(options.quantity);
+ }
+
+ if (options.price_extra !== undefined){
+ line.price_extra = options.price_extra;
+ line.set_unit_price(line.product.get_price(this.pricelist, line.get_quantity(), options.price_extra));
+ this.fix_tax_included_price(line);
+ }
+
+ if(options.price !== undefined){
+ line.set_unit_price(options.price);
+ this.fix_tax_included_price(line);
+ }
+
+ if(options.lst_price !== undefined){
+ line.set_lst_price(options.lst_price);
+ }
+
+ if(options.discount !== undefined){
+ line.set_discount(options.discount);
+ }
+
+ if (options.description !== undefined){
+ line.description += options.description;
+ }
+
+ if(options.extras !== undefined){
+ for (var prop in options.extras) {
+ line[prop] = options.extras[prop];
+ }
+ }
+ if (options.is_tip) {
+ this.is_tipped = true;
+ this.tip_amount = options.price;
+ }
+
+ var to_merge_orderline;
+ for (var i = 0; i < this.orderlines.length; i++) {
+ if(this.orderlines.at(i).can_be_merged_with(line) && options.merge !== false){
+ to_merge_orderline = this.orderlines.at(i);
+ }
+ }
+ if (to_merge_orderline){
+ to_merge_orderline.merge(line);
+ this.select_orderline(to_merge_orderline);
+ } else {
+ this.orderlines.add(line);
+ this.select_orderline(this.get_last_orderline());
+ }
+
+ 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();
+ }
+ },
+ get_selected_orderline: function(){
+ return this.selected_orderline;
+ },
+ 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);
+ }
+ this.selected_orderline = line;
+ this.selected_orderline.set_selected(true);
+ }
+ }else{
+ this.selected_orderline = undefined;
+ }
+ },
+ deselect_orderline: function(){
+ if(this.selected_orderline){
+ this.selected_orderline.set_selected(false);
+ this.selected_orderline = undefined;
+ }
+ },
+
+ /* ---- Payment Lines --- */
+ add_paymentline: function(payment_method) {
+ this.assert_editable();
+ var newPaymentline = new exports.Paymentline({},{order: this, payment_method:payment_method, pos: this.pos});
+ newPaymentline.set_amount(this.get_due());
+ this.paymentlines.add(newPaymentline);
+ this.select_paymentline(newPaymentline);
+ if(this.pos.config.cash_rounding){
+ this.selected_paymentline.set_amount(0);
+ this.selected_paymentline.set_amount(this.get_due());
+ }
+ return newPaymentline;
+ },
+ get_paymentlines: function(){
+ return this.paymentlines.models;
+ },
+ /**
+ * Retrieve the paymentline with the specified cid
+ *
+ * @param {String} cid
+ */
+ get_paymentline: function (cid) {
+ var lines = this.get_paymentlines();
+ return lines.find(function (line) {
+ return line.cid === cid;
+ });
+ },
+ remove_paymentline: function(line){
+ this.assert_editable();
+ if(this.selected_paymentline === line){
+ this.select_paymentline(undefined);
+ }
+ this.paymentlines.remove(line);
+ },
+ clean_empty_paymentlines: function() {
+ var lines = this.paymentlines.models;
+ var empty = [];
+ for ( var i = 0; i < lines.length; i++) {
+ if (!lines[i].get_amount()) {
+ empty.push(lines[i]);
+ }
+ }
+ for ( var i = 0; i < empty.length; i++) {
+ this.remove_paymentline(empty[i]);
+ }
+ },
+ select_paymentline: function(line){
+ if(line !== this.selected_paymentline){
+ if(this.selected_paymentline){
+ this.selected_paymentline.set_selected(false);
+ }
+ this.selected_paymentline = line;
+ if(this.selected_paymentline){
+ this.selected_paymentline.set_selected(true);
+ }
+ this.trigger('change:selected_paymentline',this.selected_paymentline);
+ }
+ },
+ electronic_payment_in_progress: function() {
+ return this.get_paymentlines()
+ .some(function(pl) {
+ if (pl.payment_status) {
+ return !['done', 'reversed'].includes(pl.payment_status);
+ } else {
+ return false;
+ }
+ });
+ },
+ /**
+ * Stops a payment on the terminal if one is running
+ */
+ stop_electronic_payment: function () {
+ var lines = this.get_paymentlines();
+ var line = lines.find(function (line) {
+ var status = line.get_payment_status();
+ return status && !['done', 'reversed', 'reversing', 'pending', 'retry'].includes(status);
+ });
+ if (line) {
+ line.set_payment_status('waitingCancel');
+ line.payment_method.payment_terminal.send_payment_cancel(this, line.cid).finally(function () {
+ line.set_payment_status('retry');
+ });
+ }
+ },
+ /* ---- Payment Status --- */
+ get_subtotal: function(){
+ return round_pr(this.orderlines.reduce((function(sum, orderLine){
+ return sum + orderLine.get_display_price();
+ }), 0), this.pos.currency.rounding);
+ },
+ get_total_with_tax: function() {
+ return this.get_total_without_tax() + this.get_total_tax();
+ },
+ get_total_without_tax: function() {
+ return round_pr(this.orderlines.reduce((function(sum, orderLine) {
+ return sum + orderLine.get_price_without_tax();
+ }), 0), this.pos.currency.rounding);
+ },
+ get_total_discount: function() {
+ return round_pr(this.orderlines.reduce((function(sum, orderLine) {
+ sum += (orderLine.get_unit_price() * (orderLine.get_discount()/100) * orderLine.get_quantity());
+ if (orderLine.display_discount_policy() === 'without_discount'){
+ sum += ((orderLine.get_lst_price() - orderLine.get_unit_price()) * orderLine.get_quantity());
+ }
+ return sum;
+ }), 0), this.pos.currency.rounding);
+ },
+ get_total_tax: function() {
+ if (this.pos.company.tax_calculation_rounding_method === "round_globally") {
+ // As always, we need:
+ // 1. For each tax, sum their amount across all order lines
+ // 2. Round that result
+ // 3. Sum all those rounded amounts
+ var groupTaxes = {};
+ this.orderlines.each(function (line) {
+ var taxDetails = line.get_tax_details();
+ var taxIds = Object.keys(taxDetails);
+ for (var t = 0; t<taxIds.length; t++) {
+ var taxId = taxIds[t];
+ if (!(taxId in groupTaxes)) {
+ groupTaxes[taxId] = 0;
+ }
+ groupTaxes[taxId] += taxDetails[taxId];
+ }
+ });
+
+ var sum = 0;
+ var taxIds = Object.keys(groupTaxes);
+ for (var j = 0; j<taxIds.length; j++) {
+ var taxAmount = groupTaxes[taxIds[j]];
+ sum += round_pr(taxAmount, this.pos.currency.rounding);
+ }
+ return sum;
+ } else {
+ return round_pr(this.orderlines.reduce((function(sum, orderLine) {
+ return sum + orderLine.get_tax();
+ }), 0), this.pos.currency.rounding);
+ }
+ },
+ get_total_paid: function() {
+ return round_pr(this.paymentlines.reduce((function(sum, paymentLine) {
+ if (paymentLine.is_done()) {
+ sum += paymentLine.get_amount();
+ }
+ return sum;
+ }), 0), this.pos.currency.rounding);
+ },
+ get_tax_details: function(){
+ var details = {};
+ var fulldetails = [];
+
+ this.orderlines.each(function(line){
+ var ldetails = line.get_tax_details();
+ for(var id in ldetails){
+ if(ldetails.hasOwnProperty(id)){
+ details[id] = (details[id] || 0) + ldetails[id];
+ }
+ }
+ });
+
+ for(var id in details){
+ if(details.hasOwnProperty(id)){
+ fulldetails.push({amount: details[id], tax: this.pos.taxes_by_id[id], name: this.pos.taxes_by_id[id].name});
+ }
+ }
+
+ return fulldetails;
+ },
+ // Returns a total only for the orderlines with products belonging to the category
+ get_total_for_category_with_tax: function(categ_id){
+ var total = 0;
+ var self = this;
+
+ if (categ_id instanceof Array) {
+ for (var i = 0; i < categ_id.length; i++) {
+ total += this.get_total_for_category_with_tax(categ_id[i]);
+ }
+ return total;
+ }
+
+ this.orderlines.each(function(line){
+ if ( self.pos.db.category_contains(categ_id,line.product.id) ) {
+ total += line.get_price_with_tax();
+ }
+ });
+
+ return total;
+ },
+ get_total_for_taxes: function(tax_id){
+ var total = 0;
+
+ if (!(tax_id instanceof Array)) {
+ tax_id = [tax_id];
+ }
+
+ var tax_set = {};
+
+ for (var i = 0; i < tax_id.length; i++) {
+ tax_set[tax_id[i]] = true;
+ }
+
+ this.orderlines.each(function(line){
+ var taxes_ids = line.get_product().taxes_id;
+ for (var i = 0; i < taxes_ids.length; i++) {
+ if (tax_set[taxes_ids[i]]) {
+ total += line.get_price_with_tax();
+ return;
+ }
+ }
+ });
+
+ return total;
+ },
+ get_change: function(paymentline) {
+ if (!paymentline) {
+ var change = this.get_total_paid() - this.get_total_with_tax() - this.get_rounding_applied();
+ } else {
+ var change = -this.get_total_with_tax();
+ var lines = this.paymentlines.models;
+ for (var i = 0; i < lines.length; i++) {
+ change += lines[i].get_amount();
+ if (lines[i] === paymentline) {
+ break;
+ }
+ }
+ }
+ return round_pr(Math.max(0,change), this.pos.currency.rounding);
+ },
+ get_due: function(paymentline) {
+ if (!paymentline) {
+ var due = this.get_total_with_tax() - this.get_total_paid() + this.get_rounding_applied();
+ } else {
+ var due = this.get_total_with_tax();
+ var lines = this.paymentlines.models;
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i] === paymentline) {
+ break;
+ } else {
+ due -= lines[i].get_amount();
+ }
+ }
+ }
+ return round_pr(due, this.pos.currency.rounding);
+ },
+ get_rounding_applied: function() {
+ if(this.pos.config.cash_rounding) {
+ const only_cash = this.pos.config.only_round_cash_method;
+ const paymentlines = this.get_paymentlines();
+ const last_line = paymentlines ? paymentlines[paymentlines.length-1]: false;
+ const last_line_is_cash = last_line ? last_line.payment_method.is_cash_count == true: false;
+ if (!only_cash || (only_cash && last_line_is_cash)) {
+ var remaining = this.get_total_with_tax() - this.get_total_paid();
+ var total = round_pr(remaining, this.pos.cash_rounding[0].rounding);
+ var sign = remaining > 0 ? 1.0 : -1.0;
+
+ var rounding_applied = total - remaining;
+ rounding_applied *= sign;
+ // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it.
+ if (utils.float_is_zero(rounding_applied, this.pos.currency.decimals)){
+ // https://xkcd.com/217/
+ return 0;
+ }
+ else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0 && remaining > 0) {
+ rounding_applied += this.pos.cash_rounding[0].rounding;
+ }
+ else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied > 0 && remaining < 0) {
+ rounding_applied -= this.pos.cash_rounding[0].rounding;
+ }
+ else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0 && remaining > 0){
+ rounding_applied -= this.pos.cash_rounding[0].rounding;
+ }
+ else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied < 0 && remaining < 0){
+ rounding_applied += this.pos.cash_rounding[0].rounding;
+ }
+ return sign * rounding_applied;
+ }
+ else {
+ return 0;
+ }
+ }
+ return 0;
+ },
+ has_not_valid_rounding: function() {
+ if(!this.pos.config.cash_rounding)
+ return false;
+
+ const only_cash = this.pos.config.only_round_cash_method;
+ var lines = this.paymentlines.models;
+
+ for(var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ if (only_cash && !line.payment_method.is_cash_count)
+ continue;
+
+ if(!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6))
+ return line;
+ }
+ return false;
+ },
+ is_paid: function(){
+ return this.get_due() <= 0 && this.check_paymentlines_rounding();
+ },
+ is_paid_with_cash: function(){
+ return !!this.paymentlines.find( function(pl){
+ return pl.payment_method.is_cash_count;
+ });
+ },
+ check_paymentlines_rounding: function() {
+ if(this.pos.config.cash_rounding) {
+ var cash_rounding = this.pos.cash_rounding[0].rounding;
+ var default_rounding = this.pos.currency.rounding;
+ for(var id in this.get_paymentlines()) {
+ var line = this.get_paymentlines()[id];
+ var diff = round_pr(round_pr(line.amount, cash_rounding) - round_pr(line.amount, default_rounding), default_rounding);
+ if(diff && line.payment_method.is_cash_count) {
+ return false;
+ } else if(!this.pos.config.only_round_cash_method && diff) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return true;
+ },
+ finalize: function(){
+ this.destroy();
+ },
+ destroy: function(){
+ Backbone.Model.prototype.destroy.apply(this,arguments);
+ this.pos.db.remove_unpaid_order(this);
+ },
+ /* ---- Invoice --- */
+ set_to_invoice: function(to_invoice) {
+ this.assert_editable();
+ this.to_invoice = to_invoice;
+ },
+ is_to_invoice: function(){
+ return this.to_invoice;
+ },
+ /* ---- Client / Customer --- */
+ // the client related to the current order.
+ set_client: function(client){
+ this.assert_editable();
+ this.set('client',client);
+ },
+ get_client: function(){
+ return this.get('client');
+ },
+ get_client_name: function(){
+ var client = this.get('client');
+ return client ? client.name : "";
+ },
+ get_cardholder_name: function(){
+ var card_payment_line = this.paymentlines.find(pl => pl.cardholder_name);
+ return card_payment_line ? card_payment_line.cardholder_name : "";
+ },
+ /* ---- Screen Status --- */
+ // the order also stores the screen status, as the PoS supports
+ // different active screens per order. This method is used to
+ // store the screen status.
+ set_screen_data: function(value){
+ this.screen_data['value'] = value;
+ },
+ //see set_screen_data
+ get_screen_data: function(){
+ const screen = this.screen_data['value'];
+ // If no screen data is saved
+ // no payment line -> product screen
+ // with payment line -> payment screen
+ if (!screen) {
+ if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' };
+ return { name: 'ProductScreen' };
+ }
+ if (!this.finalized && this.get_paymentlines().length > 0) {
+ return { name: 'PaymentScreen' };
+ }
+ return screen;
+ },
+ wait_for_push_order: function () {
+ return false;
+ },
+ /**
+ * @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,
+});
+
+// exports = {
+// PosModel: PosModel,
+// load_fields: load_fields,
+// load_models: load_models,
+// Orderline: Orderline,
+// Order: Order,
+// };
+return exports;
+
+});
diff --git a/addons/point_of_sale/static/src/js/payment.js b/addons/point_of_sale/static/src/js/payment.js
new file mode 100644
index 00000000..ae73f552
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/payment.js
@@ -0,0 +1,95 @@
+odoo.define('point_of_sale.PaymentInterface', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+/**
+ * Implement this interface to support a new payment method in the POS:
+ *
+ * var PaymentInterface = require('point_of_sale.PaymentInterface');
+ * var MyPayment = PaymentInterface.extend({
+ * ...
+ * })
+ *
+ * To connect the interface to the right payment methods register it:
+ *
+ * var models = require('point_of_sale.models');
+ * models.register_payment_method('my_payment', MyPayment);
+ *
+ * my_payment is the technical name of the added selection in
+ * use_payment_terminal.
+ *
+ * If necessary new fields can be loaded on any model:
+ *
+ * models.load_fields('pos.payment.method', ['new_field1', 'new_field2']);
+ */
+var PaymentInterface = core.Class.extend({
+ init: function (pos, payment_method) {
+ this.pos = pos;
+ this.payment_method = payment_method;
+ this.supports_reversals = false;
+ },
+
+ /**
+ * Call this function to enable UI elements that allow a user to
+ * reverse a payment. This requires that you implement
+ * send_payment_reversal.
+ */
+ enable_reversals: function () {
+ this.supports_reversals = true;
+ },
+
+ /**
+ * Called when a user clicks the "Send" button in the
+ * interface. This should initiate a payment request and return a
+ * Promise that resolves when the final status of the payment line
+ * is set with set_payment_status.
+ *
+ * For successful transactions set_receipt_info() should be used
+ * to set info that should to be printed on the receipt. You
+ * should also set card_type and transaction_id on the line for
+ * successful transactions.
+ *
+ * @param {string} cid - The id of the paymentline
+ * @returns {Promise} resolved with a boolean that is false when
+ * the payment should be retried. Rejected when the status of the
+ * paymentline will be manually updated.
+ */
+ send_payment_request: function (cid) {},
+
+ /**
+ * Called when a user removes a payment line that's still waiting
+ * on send_payment_request to complete. Should execute some
+ * request to ensure the current payment request is
+ * cancelled. This is not to refund payments, only to cancel
+ * them. The payment line being cancelled will be deleted
+ * automatically after the returned promise resolves.
+ *
+ * @param {} order - The order of the paymentline
+ * @param {string} cid - The id of the paymentline
+ * @returns {Promise}
+ */
+ send_payment_cancel: function (order, cid) {},
+
+ /**
+ * This is an optional method. When implementing this make sure to
+ * call enable_reversals() in the constructor of your
+ * interface. This should reverse a previous payment with status
+ * 'done'. The paymentline will be removed based on returned
+ * Promise.
+ *
+ * @param {string} cid - The id of the paymentline
+ * @returns {Promise} returns true if the reversal was successful.
+ */
+ send_payment_reversal: function (cid) {},
+
+ /**
+ * Called when the payment screen in the POS is closed (by
+ * e.g. clicking the "Back" button). Could be used to cancel in
+ * progress payments.
+ */
+ close: function () {},
+});
+
+return PaymentInterface;
+});
diff --git a/addons/point_of_sale/static/src/js/printers.js b/addons/point_of_sale/static/src/js/printers.js
new file mode 100644
index 00000000..20ea4454
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/printers.js
@@ -0,0 +1,172 @@
+odoo.define('point_of_sale.Printer', function (require) {
+"use strict";
+
+var Session = require('web.Session');
+var core = require('web.core');
+const { Gui } = require('point_of_sale.Gui');
+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() {
+ 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: async function(receipt) {
+ if (receipt) {
+ this.receipt_queue.push(receipt);
+ }
+ 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 || sendPrintResult.result === false) {
+ this.receipt_queue.length = 0;
+ return this.printResultGenerator.IoTResultError();
+ }
+ }
+ return this.printResultGenerator.Successful();
+ },
+
+ /**
+ * Generate a jpeg image from a canvas
+ * @param {DOMElement} canvas
+ */
+ process_canvas: function (canvas) {
+ return canvas.toDataURL('image/jpeg').replace('data:image/jpeg;base64,','');
+ },
+
+ /**
+ * Renders the html as an image to print it
+ * @param {String} receipt: The receipt to be printed, in HTML
+ */
+ htmlToImg: function (receipt) {
+ var self = this;
+ $('.pos-receipt-print').html(receipt);
+ var promise = new Promise(function (resolve, reject) {
+ self.receipt = $('.pos-receipt-print>.pos-receipt');
+ html2canvas(self.receipt[0], {
+ onparsed: function(queue) {
+ queue.stack.ctx.height = Math.ceil(self.receipt.outerHeight() + self.receipt.offset().top);
+ },
+ onrendered: function (canvas) {
+ $('.pos-receipt-print').empty();
+ resolve(self.process_canvas(canvas));
+ },
+ letterRendering: true,
+ })
+ });
+ return promise;
+ },
+
+ _onIoTActionResult: function (data){
+ if (this.pos && (data === false || data.result === false)) {
+ Gui.showPopup('ErrorPopup',{
+ 'title': _t('Connection to the printer failed'),
+ 'body': _t('Please check if the printer is still connected.'),
+ });
+ }
+ },
+
+ _onIoTActionFail: function () {
+ if (this.pos) {
+ Gui.showPopup('ErrorPopup',{
+ 'title': _t('Connection to IoT Box failed'),
+ 'body': _t('Please check if the IoT Box is still connected.'),
+ });
+ }
+ },
+}
+
+var Printer = core.Class.extend(PrinterMixin, {
+ init: function (url, pos) {
+ PrinterMixin.init.call(this, arguments);
+ this.pos = pos;
+ this.connection = new Session(undefined, url || 'http://localhost:8069', { use_cors: true});
+ },
+
+ /**
+ * Sends a command to the connected proxy to open the cashbox
+ * (the physical box where you store the cash). Updates the status of
+ * the printer with the answer from the proxy.
+ */
+ open_cashbox: function () {
+ var self = this;
+ return this.connection.rpc('/hw_proxy/default_printer_action', {
+ data: {
+ action: 'cashbox'
+ }
+ }).then(self._onIoTActionResult.bind(self))
+ .guardedCatch(self._onIoTActionFail.bind(self));
+ },
+
+ /**
+ * Sends the printing command the connected proxy
+ * @param {String} img : The receipt to be printed, as an image
+ */
+ send_printing_job: function (img) {
+ return this.connection.rpc('/hw_proxy/default_printer_action', {
+ data: {
+ action: 'print_receipt',
+ receipt: img,
+ }
+ });
+ },
+});
+
+return {
+ PrinterMixin: PrinterMixin,
+ Printer: Printer,
+ PrintResult,
+ PrintResultGenerator,
+}
+});
diff --git a/addons/point_of_sale/static/src/js/tours/point_of_sale.js b/addons/point_of_sale/static/src/js/tours/point_of_sale.js
new file mode 100644
index 00000000..49a6fc0a
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/tours/point_of_sale.js
@@ -0,0 +1,31 @@
+odoo.define('point_of_sale.tour', function(require) {
+"use strict";
+
+var core = require('web.core');
+var tour = require('web_tour.tour');
+
+var _t = core._t;
+
+tour.register('point_of_sale_tour', {
+ url: "/web",
+ rainbowMan: false,
+ sequence: 45,
+}, [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]',
+ content: _t("Ready to launch your <b>point of sale</b>?"),
+ width: 215,
+ position: 'right',
+ edition: 'community'
+}, {
+ trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]',
+ content: _t("Ready to launch your <b>point of sale</b>?"),
+ width: 215,
+ position: 'bottom',
+ edition: 'enterprise'
+}, {
+ trigger: ".o_pos_kanban button.oe_kanban_action_button",
+ content: _t("<p>Ready to have a look at the <b>POS Interface</b>? Let's start our first session.</p>"),
+ position: "bottom"
+}]);
+
+});
diff --git a/addons/point_of_sale/static/src/js/utils.js b/addons/point_of_sale/static/src/js/utils.js
new file mode 100644
index 00000000..7aa7b35e
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/utils.js
@@ -0,0 +1,49 @@
+odoo.define('point_of_sale.utils', function (require) {
+ 'use strict';
+
+ const { EventBus } = owl.core;
+
+ function getFileAsText(file) {
+ return new Promise((resolve, reject) => {
+ if (!file) {
+ reject();
+ } else {
+ const reader = new FileReader();
+ reader.addEventListener('load', function () {
+ resolve(reader.result);
+ });
+ reader.addEventListener('abort', reject);
+ reader.addEventListener('error', reject);
+ reader.readAsText(file);
+ }
+ });
+ }
+
+ /**
+ * This global variable is used by nextFrame to store the timer and
+ * be able to cancel it before another request for animation frame.
+ */
+ let timer = null;
+
+ /**
+ * Wait for the next animation frame to finish.
+ */
+ const nextFrame = () => {
+ return new Promise((resolve) => {
+ cancelAnimationFrame(timer);
+ timer = requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+ };
+
+ function isRpcError(error) {
+ return (
+ !(error instanceof Error) &&
+ error.message &&
+ [100, 200, 404, -32098].includes(error.message.code)
+ );
+ }
+
+ return { getFileAsText, nextFrame, isRpcError, posbus: new EventBus() };
+});
diff --git a/addons/point_of_sale/static/src/scss/customer_facing_display.scss b/addons/point_of_sale/static/src/scss/customer_facing_display.scss
new file mode 100644
index 00000000..b1c85ac6
--- /dev/null
+++ b/addons/point_of_sale/static/src/scss/customer_facing_display.scss
@@ -0,0 +1,475 @@
+// out: ../css/customer_facing_display.css, sourcemap: false, compress: false
+
+// =========== Variables ===========
+$color-gray-lighter: #f6f6f6;
+$color-gray-dark: #3E3E3E;
+
+// =========== Animations ===========
+@keyframes item_in {
+ 0% { opacity: 0; margin-top: -30px; }
+ 50% { margin-top: 0; }
+ 100% { opacity: 1; }
+}
+@-webkit-keyframes item_in {
+ 0% { opacity: 0; margin-top: -30px; }
+ 50% { margin-top: 0; }
+ 100% { opacity: 1; }
+}
+
+// =========== MIXINS ===========
+@mixin pos-bg {
+ background-position: center top;
+ background-size: contain;
+ background-repeat: no-repeat;
+
+ &[style*="url(http://placehold.it"] {
+ // Add a bg-color in case we are using a pleceholder.
+ // This will help the user to identify the right image dimension
+ // before apply customizations.
+ background-color: #ccc;
+ }
+}
+
+// =========== VENDOR PREFIX ===========
+@mixin flex-display {
+ -webkit-display: flex;
+ -moz-display: flex;
+ -ms-display: flex;
+ -o-display: flex;
+ display: flex;
+}
+@mixin flex-direction($direction) {
+ -webkit-flex-direction: $direction;
+ -moz-flex-direction: $direction;
+ -ms-flex-direction: $direction;
+ -o-flex-direction: $direction;
+ flex-direction: $direction;
+};
+@mixin flex-grow($grow) {
+ -webkit-box-flex: $grow;
+ -webkit-flex-grow: $grow;
+ -moz-box-flex: $grow;
+ -ms-flex-positive: $grow;
+ flex-grow: $grow;
+}
+@mixin flex($flex) {
+ -webkit-box-flex: $flex;
+ -webkit-flex: $flex;
+ -moz-box-flex: $flex;
+ -ms-flex: $flex;
+ flex: $flex;
+}
+@mixin align-items($align) {
+ -webkit-box-align: $align;
+ -webkit-align-items: $align;
+ -moz-box-align: $align;
+ -ms-flex-align: $align;
+ -ms-grid-row-align: $align;
+ align-items: $align;
+};
+@mixin justify-content($justify) {
+ -webkit-box-pack: $justify;
+ -webkit-justify-content: $justify;
+ -moz-box-pack: $justify;
+ -ms-flex-pack: $justify;
+ justify-content: $justify;
+}
+@mixin flex-wrap($wrap) {
+ -webkit-flex-wrap: $wrap;
+ -ms-flex-wrap: $wrap;
+ flex-wrap: $wrap;
+}
+
+
+// =========== MAIN LAYOUT ===========
+body {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: geometricPrecision;
+ font-smooth: always;
+
+ .pos-customer_facing_display {
+ background-color: $color-gray-lighter;
+ font-size: 2vw;
+ font-family: Futura, HelveticaNeue, Helvetica, Arial, "Lucida Grande", sans-serif;
+ font-weight: 300;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ @include flex-display;
+ @include flex-direction(row);
+
+ .pos-customer_products,
+ .pos-payment_info {
+ height: 100%;
+ padding: 2%;
+ @include flex-display;
+ @include flex-direction(column);
+ @include flex-grow(1);
+ }
+
+ .pos_orderlines {
+ width: 100%;
+ height: 100%;
+ @include flex-display;
+ @include flex-direction(column);
+
+ .pos_orderlines_list {
+ overflow-y: scroll;
+ padding-right: 1.5vw;
+ position: relative;
+ height: 100%;
+ }
+
+ .pos_orderlines_item {
+ margin-bottom: 1vw;
+ padding: 1%;
+ border-radius: 0.3vw;
+ height: auto;
+ @include flex(0 1 auto);
+ @include flex-display;
+ @include flex-direction(row);
+ @include align-items(center);
+
+ &:last-of-type {
+ animation: item_in 1s ease;
+ }
+
+ &.pos_orderlines_header {
+ background-color: transparent;
+ box-shadow: none;
+ animation: none;
+
+ > div {
+ &,
+ &:last-child {
+ border-left-width: 0;
+ text-align: center;
+ font-size: 70%;
+ font-weight: normal;
+ }
+ }
+
+ > div:last-child {
+ text-align: left;
+ }
+ }
+
+ > div {
+ width: 5%;
+ text-align: left;
+ margin-right: 4%;
+ font-size: 80%;
+ @include flex-grow(1);
+
+ &:first-child {
+ margin-right: 2%;
+ @include flex(1 1 1%);
+ }
+
+ &:nth-child(2) {
+ width: 40%;
+ border-left: 1px solid;
+ padding-left: 2%;
+ }
+
+ &:nth-child(3) {
+ text-align: center;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ font-weight: bold;
+ }
+
+ div {
+ background-position: center;
+ background-size: cover;
+ padding-top: 75%;
+ display: block;
+ }
+ }
+ }
+ }
+
+ .pos-payment_info {
+ max-width: 30%;
+ padding: 2% 2% 1% 2%;
+ @include flex-direction(column);
+ @include justify-content(space-between);
+
+ .pos-adv,
+ .pos-company_logo {
+ @include pos-bg;
+ }
+
+ .pos-company_logo {
+ background-image: url(/logo);
+ margin-bottom: 10%;
+ @include flex(0 0 20%);
+ }
+
+ .pos-adv {
+ margin-bottom: 5%;
+ border-bottom: 10px solid transparent;
+ box-shadow: 0 1px rgba($color-gray-lighter, 0.2);
+ @include flex(1 1 60%);
+ }
+
+ .pos-payment_info_details{
+ .pos-total,
+ .pos-paymentlines {
+ @include flex-direction(row);
+ @include flex-display;
+ @include flex-wrap(wrap);
+ @include justify-content(space-between);
+ @include align-items(baseline);
+
+ > div {
+ @include flex(1 0 48%);
+
+ &:nth-child(even) {
+ font-weight: bold;
+ font-size: 120%;
+ margin-right: 0
+ }
+ }
+ }
+
+ .pos-total {
+ font-size: 2vw;
+ }
+
+ .pos-paymentlines {
+ margin-top: 2%;
+ font-size: 1.5vw;
+ line-height: 1.3;
+ }
+
+ .pos-odoo_logo_container {
+ text-align: right;
+ margin-top: 10%;
+ @include flex(0 1 auto);
+
+ img {
+ max-width: 40px;
+ }
+ }
+ }
+ }
+ }
+
+ // =========== PORTRAIT LAYOUT ===========
+ @media all and (orientation: portrait) {
+ .pos-customer_facing_display {
+ font-size: 2vh;
+ height: 100%;
+ @include flex-direction(column);
+
+ &:before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 17vh;
+ }
+
+ .pos-payment_info .pos-adv {
+ // Move ADV on top.
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 15vh;
+ width: 99vw;
+ margin:0.5vh;
+ border-width: 0;
+ @include flex-display;
+ }
+
+ &.pos-js_no_ADV {
+ &:before {
+ display: none;
+ }
+ .pos-customer_products {
+ padding-top: 0;
+ }
+ }
+
+ .pos-customer_products {
+ padding-top: 17vh;
+ height: 72vw;
+ overflow: hidden;
+
+ .pos_orderlines {
+ @include flex(1 0 auto);
+
+ .pos_orderlines_item {
+ > div:nth-child(2) {
+ width: 30%;
+ }
+ &.pos_orderlines_header div{
+ font-size: 90%;
+ }
+ }
+
+ .pos_orderlines_list {
+ padding-right: 1.5vh;
+ height: auto;
+ .pos_orderlines_item {
+ box-shadow: 0 .1vh .1vh darken($color-gray-lighter, 10%);
+ margin-bottom: 1vh;
+ > div {
+ font-size: 100%;
+ }
+ }
+ }
+ }
+ }
+
+ .pos-payment_info {
+ max-width: 100%;
+ overflow: hidden;
+ padding-top: 0;
+ min-height: 120px;
+ @include flex(0 1 23vw);
+ @include flex-direction(row);
+ @include align-items(center);
+ @include justify-content(space-between);
+
+ .pos-company_logo {
+ margin: 0;
+ background-position: left center;
+ margin-right: 5%;
+ height: 100%;
+ padding: 0;
+ @include flex(1 1 20%);
+ }
+
+ .pos-payment_info_details {
+ @include flex(0 1 50%);
+ @include flex-direction(column);
+ min-width: 170px;
+
+ .pos-total {
+ font-size: 3vw;
+ .pos_total-amount {
+ font-size: 3.5vw;
+ }
+ }
+
+ .pos-paymentlines {
+ margin-top: 2%;
+ font-size: 80%;
+ line-height: 1.2;
+ }
+ .pos-odoo_logo_container {
+ position: absolute;
+ right: 3%;
+ bottom: 1%;
+ }
+ }
+ }
+ }
+ }
+
+ @media all and (orientation: portrait) and (max-width: 340px ) {
+ .pos-customer_facing_display {
+ .pos-customer_products .pos_orderlines {
+ .pos_orderlines_list{
+ padding-right: 0;
+ .pos_orderlines_item > div{
+ font-size: 70%;
+ }
+ }
+ .pos_orderlines_header > div {
+ font-size: 60%;
+ &:last-child {
+ text-align: center;
+ }
+ }
+ }
+ .pos-payment_info {
+
+ .pos-company_logo {
+ display: none!important;
+ }
+ .pos-payment_info_details {
+ @include flex(1 0 100%);
+
+ .pos-total {
+ font-size: 6vw;
+ .pos_total-amount {
+ font-size: 6.5vw;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+
+// @media all and (max-width: 340px ) {
+// }
+
+body {
+ // =========== UTILITY CLASSES ===========
+ .pos-hidden {
+ opacity: 0;
+ }
+}
+
+
+// =========== PALETTE GENERATOR ===========
+@mixin palette-variant (
+ $bg-info: $color-gray-dark,
+ $text-info: $color-gray-lighter,
+ $bg-products: $color-gray-lighter,
+ $text-products: lighten($color-gray-dark, 10%),
+ $card-bg: white,
+ $card-text: $color-gray-dark,
+ $card-shadow: darken($bg-products, 30%)) {
+
+ .pos-payment_info {
+ background: $bg-info;
+ color: $text-info;
+ }
+
+ .pos-customer_products {
+ background: $bg-products;
+ color: $text-products;
+
+ .pos_orderlines_list .pos_orderlines_item {
+ background-color: $card-bg;
+ color: $card-text;
+ box-shadow: 0 .1vh .1vh $card-shadow;
+ div:nth-child(2) {
+ border-color: rgba($card-text, 0.3);
+ }
+ }
+ }
+
+ @media all and (orientation: portrait) {
+ &:before {
+ background: $bg-info;
+ }
+ }
+}
+
+// =========== PALETTES ===========
+// Those are kept for compatibility for now (previously there was a feature
+// which allowed to change the default pos-palette_01 class.
+.pos-palette_01 { @include palette-variant; }
+.pos-palette_02 { @include palette-variant(#364152, #e6e7e8, #ecf2f6, #364152, white, $color-gray-dark, #364152 ); }
+.pos-palette_03 { @include palette-variant(#1BA39C, $color-gray-lighter, #ececec ); }
+.pos-palette_04 { @include palette-variant(#0b7b6c, $color-gray-lighter, #efeeec); }
+.pos-palette_05 { @include palette-variant(#E26868, $color-gray-lighter, #ececec ); }
+.pos-palette_06 { @include palette-variant(#9E373B, $color-gray-lighter); }
+.pos-palette_07 { @include palette-variant(#ce9934, white, #ececec ); }
+.pos-palette_08 { @include palette-variant(#a48c77, $color-gray-lighter, #ececec ); }
+.pos-palette_09 { @include palette-variant(linear-gradient(30deg, #014d43, #127e71), $color-gray-lighter, #ececec ); }
+.pos-palette_10 { @include palette-variant(linear-gradient(30deg, #e2316c, #ea4c89), white, #ececec ); }
+.pos-palette_11 { @include palette-variant(linear-gradient(30deg, #362b3d, #5b4a63), white, #ececec ); }
+.pos-palette_12 { @include palette-variant(#434343, #e6e6e6, #5b5b5b, #bdb9b9, #f5f5f5); }
+.pos-palette_13 { @include palette-variant(linear-gradient(30deg, #1a1b1f, #3d3f45), white, #a2a2ab, $color-gray-lighter, $color-gray-lighter); }
diff --git a/addons/point_of_sale/static/src/scss/pos_dashboard.scss b/addons/point_of_sale/static/src/scss/pos_dashboard.scss
new file mode 100644
index 00000000..469fd998
--- /dev/null
+++ b/addons/point_of_sale/static/src/scss/pos_dashboard.scss
@@ -0,0 +1,5 @@
+.o_kanban_view.o_kanban_dashboard.o_pos_kanban.o_kanban_ungrouped {
+ .o_kanban_record {
+ width: 500px;
+ }
+}
diff --git a/addons/point_of_sale/static/src/sounds/bell.wav b/addons/point_of_sale/static/src/sounds/bell.wav
new file mode 100644
index 00000000..660779c5
--- /dev/null
+++ b/addons/point_of_sale/static/src/sounds/bell.wav
Binary files differ
diff --git a/addons/point_of_sale/static/src/sounds/error.wav b/addons/point_of_sale/static/src/sounds/error.wav
new file mode 100644
index 00000000..472f3910
--- /dev/null
+++ b/addons/point_of_sale/static/src/sounds/error.wav
Binary files differ
diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml
new file mode 100644
index 00000000..17593b2f
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Chrome.xml
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="Chrome" owl="1">
+ <div class="pos" t-att-class="{ 'big-scrollbars': state.hasBigScrollBars }">
+ <div class="pos-receipt-print"></div>
+ <div class="pos-topheader" t-att-class="{ oe_hidden: state.uiState !== 'READY' }">
+ <div t-if="tempScreen.isShown" class="block-top-header" />
+ <div class="pos-branding" t-if= "!env.isMobile">
+ <img class="pos-logo" t-on-click="trigger('toggle-debug-widget')"
+ src="/point_of_sale/static/src/img/logo.png" alt="Logo" />
+ <TicketButton isTicketScreenShown="isTicketScreenShown" />
+ </div>
+ <div class="pos-rightheader">
+ <TicketButton isTicketScreenShown="isTicketScreenShown" t-if="env.isMobile" />
+ <div class="search-bar-portal" />
+ <div class="status-buttons-portal" />
+ </div>
+ </div>
+ <t t-if="state.uiState === 'READY'">
+ <Portal target="'.pos .status-buttons-portal'">
+ <div class="status-buttons">
+ <t t-if="!env.isMobile">
+ <CashierName />
+ </t>
+ <OrderManagementButton t-if="env.pos.config.manage_orders" />
+ <SaleDetailsButton t-if="env.pos.proxy.printer" />
+ <ProxyStatus t-if="env.pos.config.use_proxy" />
+ <ClientScreenButton t-if="clientScreenButtonIsShown" />
+ <SyncNotification />
+ <HeaderButton />
+ </div>
+ </Portal>
+ <div class="pos-content">
+ <div class="window">
+ <div class="subwindow">
+ <div class="subwindow-container">
+ <div class="subwindow-container-fix screens">
+ <t isShown="!tempScreen.isShown" t-component="mainScreen.component"
+ t-props="mainScreenProps" t-key="mainScreen.name" />
+ <t t-if="tempScreen.isShown" t-component="tempScreen.component"
+ t-props="tempScreenProps" t-key="tempScreen.name" />
+ </div>
+ </div>
+ </div>
+ </div>
+ <DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown"
+ t-transition="fade" />
+ </div>
+ </t>
+
+ <div t-if="['LOADING', 'CLOSING'].includes(state.uiState)" class="loader" t-transition="swing">
+ <div class="loader-feedback">
+ <h1 class="message">
+ <t t-esc="loading.message" />
+ </h1>
+ <div class="progressbar">
+ <div class="progress" t-ref="progressbar"></div>
+ </div>
+ <div t-if="loading.skipButtonIsShown" class="button skip" t-on-click="trigger('loading-skip-callback')">
+ Skip
+ </div>
+ </div>
+ </div>
+
+ <!-- Allow popups to be visible at any state of the ui. -->
+ <div t-if="popup.isShown" class="popups">
+ <t t-component="popup.component" t-props="popupProps"
+ t-key="popup.name" />
+ </div>
+
+ <NotificationSound t-if="state.sound.src" sound="state.sound" />
+ </div>
+ </t>
+
+ <t t-name="CustomerFacingDisplayHead">
+ <div class="resources">
+ <base t-att-href="origin" />
+ <link href="/point_of_sale/static/src/css/customer_facing_display.css"
+ rel="stylesheet" />
+ <script type="text/javascript">
+ // This function needs to be named that way, call it the foreign JS API
+ // The iotbox will execute it, with the behavior intended
+ function foreign_js() {
+ if ($('.pos-adv').hasClass('pos-hidden')) {
+ $('.pos-customer_facing_display').addClass('pos-js_no_ADV');
+ }
+ $(window).on('resize', function () {
+ $('.pos-customer_facing_display').toggleClass('pos-js_no_ADV', $('.pos-adv').hasClass('pos-hidden'));
+ }).trigger('resize');
+ };
+ </script>
+ </div>
+ </t>
+
+ <t t-name="CustomerFacingDisplayOrderLines">
+ <t t-foreach="orderlines" t-as="orderline">
+ <div class="pos_orderlines_item">
+ <div>
+ <div t-attf-style="background-image:url(#{orderline.product.image_base64})" />
+ </div>
+ <div>
+ <t t-esc="orderline.get_full_product_name()" />
+ </div>
+ <div>
+ <t t-esc="orderline.get_quantity_str()" />
+ </div>
+ <div>
+ <t t-esc="pos.format_currency(orderline.get_display_price())" />
+ </div>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="CustomerFacingDisplayPaymentLines">
+ <t t-foreach="order.get_paymentlines()" t-as="paymentline">
+ <div>
+ <span>
+ <t t-esc="paymentline.name" /></span>
+ </div>
+ <div>
+ <span>
+ <t t-esc="pos.format_currency(paymentline.get_amount())" /></span>
+ </div>
+ </t>
+ <t t-if="order.get_paymentlines().length > 0">
+ <div>
+ <span class="pos-change_title">Change:</span>
+ </div>
+ <div>
+ <span class="pos-change_amount">
+ <t t-esc="pos.format_currency(order.get_change())" /></span>
+ </div>
+ </t>
+ </t>
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml
new file mode 100644
index 00000000..41b7ee69
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CashierName" owl="1">
+ <div class="oe_status">
+ <span class="username">
+ <t t-esc="username" />
+ </span>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml
new file mode 100644
index 00000000..bbcb1167
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientScreenButton" owl="1">
+ <div class="oe_status" t-on-click="onClick">
+ <span class="message"><t t-esc="message" /></span>
+ <div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange">
+ <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Warning" title="Client Screen Warning"></i>
+ </div>
+ <div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red">
+ <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Disconnected" title="Client Screen Disconnected"></i>
+ </div>
+ <div t-if="state.status === 'success'" class="js_connected oe_icon oe_green">
+ <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Connected" title="Client Screen Connected"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml
new file mode 100644
index 00000000..6e67512e
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="DebugWidget" owl="1">
+ <Draggable limitArea="'.pos'">
+ <div class="debug-widget">
+ <header class="drag-handle">
+ <h1>Debug Window</h1>
+ </header>
+ <div class="toggle" t-on-click="trigger('toggle-debug-widget')" title="Dismiss"
+ role="img" aria-label="Dismiss"><i class="fa fa-times" /></div>
+ <div class="content">
+ <p class="category">Electronic Scale</p>
+ <ul>
+ <li>
+ <input t-model="state.weightInput" type="text" class="weight"></input>
+ </li>
+ <li class="button set_weight" t-on-click="setWeight">Set Weight</li>
+ <li class="button reset_weight" t-on-click="resetWeight">Reset</li>
+ </ul>
+
+ <p class="category">Barcode Scanner</p>
+ <ul>
+ <li>
+ <input t-model="state.barcodeInput" type="text" class="ean"></input>
+ </li>
+ <li class="button barcode" t-on-click="barcodeScan">Scan</li>
+ <li class="button custom_ean" t-on-click="barcodeScanEAN">Scan EAN-13</li>
+ </ul>
+
+ <p class="category">Orders</p>
+
+ <ul>
+ <li class="button" t-on-click="deleteOrders">
+ Delete Paid Orders
+ </li>
+ <li class="button" t-on-click="deleteUnpaidOrders">
+ Delete Unpaid Orders
+ </li>
+ <li t-if="!state.isPaidOrdersReady" class="button"
+ t-on-click="preparePaidOrders">
+ Export Paid Orders
+ </li>
+ <a t-else="" t-att-download="paidOrdersFilename" t-att-href="paidOrdersURL"
+ t-on-click="state.isPaidOrdersReady = !state.isPaidOrdersReady">
+ <li class="button">
+ Download Paid Orders
+ </li>
+ </a>
+ <li t-if="!state.isUnpaidOrdersReady" class="button"
+ t-on-click="prepareUnpaidOrders">
+ Export Unpaid Orders
+ </li>
+ <a t-else="" t-att-download="unpaidOrdersFilename"
+ t-att-href="unpaidOrdersURL"
+ t-on-click="state.isUnpaidOrdersReady = !state.isUnpaidOrdersReady">
+ <li class="button">
+ Download Unpaid Orders
+ </li>
+ </a>
+ <li class="button import_orders" style="position:relative">
+ Import Orders
+ <input t-on-change="importOrders" type="file"
+ style="opacity:0;position:absolute;top:0;left:0;right:0;bottom:0;margin:0;cursor:pointer" />
+ </li>
+ </ul>
+
+ <p class="category">Hardware Status</p>
+ <ul>
+ <li class="status weighing">Weighing</li>
+ <li class="button display_refresh" t-on-click="refreshDisplay">
+ Refresh Display
+ </li>
+ </ul>
+ <p class="category">Hardware Events</p>
+ <ul>
+ <li class="event" t-ref="open_cashbox">Open Cashbox</li>
+ <li class="event" t-ref="print_receipt">Print Receipt</li>
+ <li class="event" t-ref="scale_read">Read Weighing Scale</li>
+ </ul>
+ <p class="category">Others</p>
+ <ul>
+ <li class="event">
+ <span>Buffer: </span>
+ <t t-esc="bufferRepr" />
+ </li>
+ </ul>
+ </div>
+ </div>
+ </Draggable>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml
new file mode 100644
index 00000000..19d9c7c8
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="HeaderButton" owl="1">
+ <div class="header-button close_button" t-att-class="{ confirm: state.label === 'Confirm' }"
+ t-on-click="onClick">
+ <t t-esc="translatedLabel" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml
new file mode 100644
index 00000000..062e11c3
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderManagementButton" owl="1">
+ <div class="oe_status order-management" t-on-click="onClick">
+ <div class="oe_icon oe_green">
+ <i class="fa fa-fw fa-search" role="img" aria-label="Order Management Button" title="Order Management Button"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml
new file mode 100644
index 00000000..3bcbef6d
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProxyStatus" owl="1">
+ <div class="oe_status js_proxy" t-on-click="onClick">
+ <span t-if="state.msg and !env.isMobile" class="js_msg">
+ <t t-esc="state.msg" />
+ </span>
+ <span t-if="state.status === 'connected'" class="js_connected oe_green">
+ <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Connected"
+ title="Proxy Connected"></i>
+ </span>
+ <span t-if="state.status === 'connecting'" class="js_connecting">
+ <i class="fa fa-fw fa-spin fa-spinner" role="img" aria-label="Connecting to Proxy"
+ title="Connecting to Proxy"></i>
+ </span>
+ <span t-if="state.status === 'warning'" class="js_warning oe_orange">
+ <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Warning"
+ title="Proxy Warning"></i>
+ </span>
+ <span t-if="state.status === 'disconnected'" class="js_disconnected oe_red">
+ <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Disconnected"
+ title="Proxy Disconnected"></i>
+ </span>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml
new file mode 100644
index 00000000..dc5ecc04
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SaleDetailsButton" owl="1">
+ <div class="oe_status">
+ <div class="js_connected oe_icon">
+ <i class="fa fa-fw fa-print" role="img" aria-label="Print" t-on-click="onClick"
+ title="Print a report with all the sales of the current PoS Session"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml
new file mode 100644
index 00000000..4a08c7ac
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SyncNotification" owl="1">
+ <div class="oe_status" t-on-click="onClick">
+ <span t-if="state.msg" class="js_msg">
+ <t t-esc="state.msg" />
+ <span> </span>
+ </span>
+ <div t-if="state.status === 'connected'" class="js_connected oe_icon oe_green">
+ <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Connected"
+ title="Synchronisation Connected"></i>
+ </div>
+ <div t-if="state.status === 'connecting'" class="js_connecting oe_icon">
+ <i class="fa fa-fw fa-spin fa-spinner" role="img"
+ aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i>
+ </div>
+ <div t-if="state.status === 'disconnected'" class="js_disconnected oe_icon oe_red">
+ <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Disconnected"
+ title="Synchronisation Disconnected"></i>
+ </div>
+ <div t-if="state.status === 'error'" class="js_error oe_icon oe_red">
+ <i class="fa fa-fw fa-warning" role="img" aria-label="Synchronisation Error"
+ title="Synchronisation Error"></i>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml
new file mode 100644
index 00000000..8a1a3a32
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="TicketButton" owl="1">
+ <div class="ticket-button" t-att-class="{ highlight: props.isTicketScreenShown }" t-on-click="onClick">
+ <div class="with-badge" t-att-badge="count">
+ <i class="fa fa-ticket" aria-hidden="true"></i>
+ </div>
+ <div>Orders</div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Misc/Draggable.xml b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml
new file mode 100644
index 00000000..c0449381
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="Draggable" owl="1">
+ <t t-slot="default"></t>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml b/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml
new file mode 100644
index 00000000..883631a2
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="MobileOrderWidget" owl="1">
+ <div class="switchpane">
+ <t t-if="pane === 'right'">
+ <button class="btn-switchpane" t-on-click="trigger('click-pay')">
+ <h1>Pay</h1>
+ <span><t t-esc="total" /></span>
+ </button>
+ <button class="btn-switchpane secondary" t-on-click="trigger('switchpane')">
+ <h1>Review</h1>
+ <span><t t-esc="items_number"/> items</span>
+ </button>
+ </t>
+ <t t-if="pane === 'left'">
+ <button class="btn-switchpane" t-on-click="trigger('switchpane')"><h1>Back</h1></button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml
new file mode 100644
index 00000000..6467e807
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="NotificationSound" owl="1">
+ <audio t-att-src="props.sound.src" autoplay="true"></audio>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml
new file mode 100644
index 00000000..a480f169
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="point_of_sale.SearchBar" owl="1">
+ <div class="pos-search-bar">
+ <div class="search">
+ <span class="search-icon"><i class="fa fa-search"></i></span>
+ <input class="radius-left" t-att-class="{ 'radius-right': !props.config.filter.show }"
+ t-model="state.searchInput" t-on-keydown="onKeydown" type="text" t-att-placeholder="placeholder" />
+ <ul t-if="state.showSearchFields and state.searchInput" class="fields">
+ <t t-foreach="config.searchFields" t-as="value" t-key="value_index">
+ <li t-att-class="{ highlight: value_index == state.selectedFieldId }"
+ t-on-click="onClickSearchField(value_index)">
+ <span class="field">
+ <t t-esc="value"></t>
+ </span>
+ <span>: </span>
+ <span class="term">
+ <t t-esc="state.searchInput"></t>
+ </span>
+ </li>
+ </t>
+ </ul>
+ </div>
+ <div t-if="props.config.filter.show" class="filter radius-right"
+ t-on-click.stop="state.showFilterOptions = !state.showFilterOptions">
+ <span class="down-icon">
+ <i class="fa fa-chevron-down" aria-hidden="true"></i>
+ </span>
+ <span>
+ <t t-esc="state.selectedFilter" />
+ </span>
+ <ul t-if="state.showFilterOptions" class="options">
+ <t t-foreach="config.filter.options" t-as="option" t-key="option">
+ <li t-on-click="selectFilter(option)">
+ <t t-esc="option"></t>
+ </li>
+ </t>
+ </ul>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml
new file mode 100644
index 00000000..f3b22b61
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ConfirmPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-confirm">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <main class="body">
+ <t t-esc=" props.body" />
+ </main>
+ <footer class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml
new file mode 100644
index 00000000..b33a5161
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="EditListInput" owl="1">
+ <div>
+ <input type="text" t-model="props.item.text" class="popup-input list-line-input"
+ placeholder="Serial/Lot Number" t-on-keyup="onKeyup" />
+ <i class="oe_link_icon fa fa-trash-o" role="img" aria-label="Remove" title="Remove"
+ t-on-click="trigger('remove-item', props.item)"></i>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml
new file mode 100644
index 00000000..9b6d6354
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="EditListPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-text">
+ <header class="title">
+ <t t-esc="props.title" />
+ </header>
+ <main class="list-lines" t-on-remove-item="removeItem"
+ t-on-create-new-item="createNewItem">
+ <t t-foreach="state.array" t-as="item" t-key="item._id">
+ <EditListInput item="item" />
+ </t>
+ </main>
+ <footer class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ Ok
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ Cancel
+ </div>
+ </footer>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml
new file mode 100644
index 00000000..6b455fca
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ErrorBarcodePopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-barcode">
+ <header class="title drag-handle">
+ <span>Unknown Barcode</span>
+ <br />
+ <span class="barcode">
+ <t t-esc="props.code" />
+ </span>
+ </header>
+ <main class="body">
+ <t t-esc="translatedMessage" />
+ </main>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="confirm">
+ Ok
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml
new file mode 100644
index 00000000..0f2e19e3
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ErrorPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-error">
+ <p class="title">
+ <t t-esc="props.title" />
+ </p>
+ <p class="body">
+ <t t-esc="props.body" />
+ </p>
+ <div class="footer">
+ <div class="button cancel" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml
new file mode 100644
index 00000000..e7552c7c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ErrorTracebackPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-error">
+ <header class="title">
+ <t t-esc="props.title" />
+ </header>
+ <main class="body traceback">
+ <t t-esc="props.body" />
+ </main>
+ <footer class="footer">
+ <div t-if="!props.exitButtonIsShown" class="button cancel" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div t-if="props.exitButtonIsShown" class="button cancel" t-on-click="trigger(props.exitButtonTrigger)">
+ <t t-esc="props.exitButtonText" />
+ </div>
+ <a t-att-download="tracebackFilename" t-att-href="tracebackUrl">
+ <div class="button icon download">
+ <i class="fa fa-download" role="img"
+ aria-label="Download error traceback"
+ title="Download error traceback"></i>
+ </div>
+ </a>
+ <div class="button icon email" t-on-click="emailTraceback">
+ <i class="fa fa-paper-plane" role="img" aria-label="Send by email"
+ title="Send by email"></i>
+ </div>
+ </footer>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml
new file mode 100644
index 00000000..41d37ee5
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="NumberPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-number" t-att-class="{ 'popup-password': props.isPassword }">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <div class="popup-input value active">
+ <t t-esc="inputBuffer" />
+ </div>
+ <div class="popup-numpad">
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('1')">1</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('2')">2</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('3')">3</button>
+ <t t-if="props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+1')">+1</button>
+ </t>
+ <t t-if="!props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+10')">+10</button>
+ </t>
+ <br />
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('4')">4</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('5')">5</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('6')">6</button>
+ <t t-if="props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+2')">+2</button>
+ </t>
+ <t t-if="!props.cheap">
+ <button class="mode-button add" t-on-mousedown.prevent="sendInput('+20')">+20</button>
+ </t>
+ <br />
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('7')">7</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('8')">8</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('9')">9</button>
+ <button t-if="!props.isPassword" class="input-button number-char" t-on-mousedown.prevent="sendInput('-')">-</button>
+ <br />
+ <button class="input-button numpad-char" t-on-mousedown.prevent="sendInput('Delete')">C</button>
+ <button class="input-button number-char" t-on-mousedown.prevent="sendInput('0')">0</button>
+ <button class="input-button number-char dot" t-on-mousedown.prevent="sendInput(decimalSeparator)">
+ <t t-esc="decimalSeparator" /></button>
+ <button class="input-button numpad-backspace" t-on-mousedown.prevent="sendInput('Backspace')">
+ <img style="pointer-events: none;"
+ src="/point_of_sale/static/src/img/backspace.png" width="24"
+ height="21" alt="Backspace" />
+ </button>
+ <br />
+ </div>
+ <footer class="footer centered">
+ <div class="button cancel" t-on-mousedown.prevent="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ <div class="button confirm" t-on-mousedown.prevent="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml
new file mode 100644
index 00000000..9950bc3d
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OfflineErrorPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-error">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <main class="body traceback"><t t-esc="props.body"/></main>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="cancel">
+ Ok
+ </div>
+ <div class="button dont-show-again" t-on-click="dontShowAgain">
+ Don't show again
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml
new file mode 100644
index 00000000..b2f142b9
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderImportPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-import">
+ <header class="title drag-handle">
+ <span>Finished Importing Orders</span>
+ </header>
+ <ul class="body">
+ <li>Successfully imported <b><t t-esc="props.report.paid or 0" /></b> paid orders</li>
+ <li>Successfully imported <b><t t-esc="props.report.unpaid or 0" /></b> unpaid orders</li>
+ <t t-if="unpaidSkipped">
+ <li><b><t t-esc="unpaidSkipped"/></b> unpaid orders could not be imported
+ <ul>
+ <li><b><t t-esc="props.report.unpaid_skipped_existing or 0" /></b> were duplicates of existing orders</li>
+ <li><b><t t-esc="props.report.unpaid_skipped_session or 0" /></b> belong to another session:
+ <t t-if="props.report.unpaid_skipped_sessions">
+ <ul>
+ <li>Session ids: <b><t t-esc="props.report.unpaid_skipped_sessions" /></b></li>
+ </ul>
+ </t>
+ </li>
+ </ul>
+ </li>
+ </t>
+ </ul>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.confirmText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml
new file mode 100644
index 00000000..fb863754
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductConfiguratorPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-text popup-lg product-configurator-popup">
+ <header class="title">
+ <t t-esc="props.product.display_name" />
+ </header>
+
+ <main class="body product_configurator_attributes col-lg-4 col-md-6 col-sm-12">
+ <div t-foreach="props.attributes" t-as="attribute" class="attribute">
+ <div class="attribute_name" t-esc="attribute.name"/>
+ <RadioProductAttribute t-if="attribute.display_type === 'radio'" attribute="attribute"/>
+ <SelectProductAttribute t-elif="attribute.display_type === 'select'" attribute="attribute"/>
+ <ColorProductAttribute t-elif="attribute.display_type === 'color'" attribute="attribute"/>
+ </div>
+ </main>
+
+ <footer class="footer">
+ <div class="button highlight confirm" t-on-click="confirm">
+ Add
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ Cancel
+ </div>
+ </footer>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="RadioProductAttribute" owl="1">
+ <div class="configurator_radio">
+ <div t-foreach="values" t-as="value">
+ <input type="radio" t-model="state.selected_value" t-att-name="attribute.id"
+ t-attf-id="{{ attribute.id }}_{{ value.id }}" t-att-value="value.id"/>
+
+ <label t-attf-for="{{ attribute.id }}_{{ value.id }}">
+ <div class="radio_attribute_label">
+ <t t-esc="value.name"/>
+ <span t-if="value.price_extra" class="price_extra">
+ + <t t-esc="env.pos.format_currency(value.price_extra)"/>
+ </span>
+ </div>
+
+ <t t-if="value.id == state.selected_value &amp;&amp; value.is_custom">
+ <input class="custom_value" type="text" t-model="state.custom_value"/>
+ </t>
+ </label>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="SelectProductAttribute" owl="1">
+ <div>
+ <t t-set="is_custom" t-value="false"/>
+
+ <select class="configurator_select" t-model="state.selected_value">
+ <option t-foreach="values" t-as="value" t-att-value="value.id">
+ <t t-set="is_custom" t-value="is_custom || (value.is_custom &amp;&amp; value.id == state.selected_value)"/>
+ <t t-esc="value.name"/>
+ <t t-if="value.price_extra">
+ + <t t-esc="env.pos.format_currency(value.price_extra)"/>
+ </t>
+ </option>
+ </select>
+
+ <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/>
+ </div>
+ </t>
+
+ <t t-name="ColorProductAttribute" owl="1">
+ <div>
+ <t t-set="is_custom" t-value="false"/>
+
+ <ul class="color_attribute_list">
+ <li t-foreach="values" t-as="value" class="color_attribute_list_item">
+ <t t-set="is_custom" t-value="is_custom || (value.is_custom &amp;&amp; value.id == state.selected_value)"/>
+ <label t-attf-class="configurator_color {{ value.id == state.selected_value ? 'active' : '' }}"
+ t-attf-style="background-color: {{ value.html_color }};" t-att-data-color="value.name">
+ <input type="radio" t-model="state.selected_value" t-att-value="value.id" t-att-name="attribute.id"/>
+ </label>
+ </li>
+ </ul>
+
+ <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml
new file mode 100644
index 00000000..b9ca1bc7
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SelectionPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-selection">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <div class="selection scrollable-y">
+ <t t-foreach="props.list" t-as="item" t-key="item.id">
+ <div class="selection-item" t-att-class="{ selected: item.isSelected }"
+ t-on-click="selectItem(item.id)">
+ <t t-esc="item.label" />
+ </div>
+ </t>
+ </div>
+ <footer class="footer">
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml
new file mode 100644
index 00000000..a142c995
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="TextAreaPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <Draggable>
+ <div class="popup popup-textarea">
+ <header class="title drag-handle">
+ <t t-esc="props.title" />
+ </header>
+ <textarea t-model="state.inputValue" t-ref="input"></textarea>
+ <footer class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </footer>
+ </div>
+ </Draggable>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml
new file mode 100644
index 00000000..cc28d706
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="TextInputPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-textinput">
+ <header class="title">
+ <t t-esc="props.title" />
+ </header>
+ <div class="div">
+ <p>
+ <t t-esc="props.body" />
+ </p>
+ <input type="text" t-model="state.inputValue" t-ref="input" />
+ </div>
+ <div class="footer">
+ <div class="button confirm" t-on-click="confirm">
+ <t t-esc="props.confirmText" />
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ <t t-esc="props.cancelText" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml
new file mode 100644
index 00000000..cce9616e
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SaleDetailsReport" owl="1">
+ <div class="pos-receipt">
+ <t t-if="pos.company_logo_base64">
+ <img class="pos-receipt-logo" t-att-src="pos.company_logo_base64" alt="Logo"/>
+ <br/>
+ </t>
+ <t t-if="!pos.company_logo_base64" class="pos-receipt-center-align">
+ <h1 t-esc="pos.company.name" />
+ <br/>
+ </t>
+ <br /><br />
+
+ <div class="orderlines">
+ <t t-foreach="products" t-as="line" t-key="line.product_id">
+ <div>
+ <t t-esc="line.product_name.substr(0,20)" />
+ <span class="pos-receipt-right-align">
+ <t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])" />
+ <t t-if="line.uom !== 'Units'">
+ <t t-esc="line.uom" />
+ </t>
+ x
+ <t t-esc="pos.format_currency_no_symbol(line.price_unit)" />
+ </span>
+ </div>
+ <t t-if="line.discount !== 0">
+ <div class="pos-receipt-left-padding">Discount: <t t-esc="line.discount" />%</div>
+ </t>
+ </t>
+ </div>
+
+ <br/>
+ <div>------------------------</div>
+ <br/>
+
+ <div>
+ Payments:
+ </div>
+ <div t-foreach="payments" t-as="payment">
+ <t t-esc="payment.name" />
+ <span t-esc="pos.format_currency_no_symbol(payment.total)" class="pos-receipt-right-align"/>
+ </div>
+
+ <br/>
+ <div>------------------------</div>
+ <br/>
+
+ <div>
+ Taxes:
+ </div>
+ <div t-foreach="taxes" t-as="tax">
+ <t t-esc="tax.name" />
+ <span t-esc="pos.format_currency_no_symbol(tax.tax_amount)" class="pos-receipt-right-align"/>
+ </div>
+
+ <br/>
+ <div>------------------------</div>
+ <br/>
+
+ <div>
+ Total:
+ <span t-esc="pos.format_currency_no_symbol(total_paid)" class="pos-receipt-right-align"/>
+ </div>
+
+ <br/>
+ <div class="pos-receipt-order-data">
+ <div><t t-esc="date" /></div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml
new file mode 100644
index 00000000..5699dc64
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml
@@ -0,0 +1,119 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientDetailsEdit" owl="1">
+ <section class="client-details edit">
+ <div class="client-picture">
+ <t t-if="partnerImageUrl">
+ <img t-att-src="partnerImageUrl" alt="Partner"
+ style="width: 64px; height: 64px; object-fit: cover;" />
+ </t>
+ <t t-else="">
+ <i class="fa fa-camera" role="img" aria-label="Picture" title="Picture"></i>
+ </t>
+ <input type="file" class="image-uploader" t-on-change="uploadImage" />
+ </div>
+ <input class="detail client-name" name="name" t-att-value="props.partner.name"
+ placeholder="Name" t-on-change="captureChange" />
+ <div class="client-details-box clearfix">
+ <div class="client-details-left">
+ <div class="client-detail">
+ <span class="label">Street</span>
+ <input class="detail client-address-street" name="street"
+ t-on-change="captureChange" t-att-value="props.partner.street || ''"
+ placeholder="Street" />
+ </div>
+ <div class="client-detail">
+ <span class="label">City</span>
+ <input class="detail client-address-city" name="city"
+ t-on-change="captureChange" t-att-value="props.partner.city || ''"
+ placeholder="City" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Postcode</span>
+ <input class="detail client-address-zip" name="zip"
+ t-on-change="captureChange" t-att-value="props.partner.zip || ''"
+ placeholder="ZIP" />
+ </div>
+ <div class="client-detail">
+ <span class="label">State</span>
+ <select class="detail client-address-states needsclick" name="state_id"
+ t-on-change="captureChange">
+ <option value="">None</option>
+ <t t-foreach="env.pos.states" t-as="state" t-key="state.id">
+ <option t-if="props.partner.country_id[0] == state.country_id[0]"
+ t-att-value="state.id"
+ t-att-selected="props.partner.state_id ? ((state.id === props.partner.state_id[0]) ? true : undefined) : undefined">
+ <t t-esc="state.name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ <div class="client-detail">
+ <span class="label">Country</span>
+ <select class="detail client-address-country needsclick" name="country_id"
+ t-on-change="captureChange">
+ <option value="">None</option>
+ <t t-foreach="env.pos.countries" t-as="country" t-key="country.id">
+ <option t-att-value="country.id"
+ t-att-selected="props.partner.country_id ? ((country.id === props.partner.country_id[0]) ? true : undefined) : undefined">
+ <t t-esc="country.name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ </div>
+ <div class="client-details-right">
+ <div class="client-detail">
+ <span class="label">Language</span>
+ <select class="detail client-lang needsclick" name="lang"
+ t-on-change="captureChange">
+ <t t-foreach="env.pos.langs" t-as="lang" t-key="lang.id">
+ <option t-att-value="lang.code"
+ t-att-selected="props.partner.lang ? ((lang.code === props.partner.lang) ? true : undefined) : lang.code === env.pos.user.lang? true : undefined">
+ <t t-esc="lang.name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ <div class="client-detail">
+ <span class="label">Email</span>
+ <input class="detail client-email" name="email" type="email"
+ t-on-change="captureChange"
+ t-att-value="props.partner.email || ''" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Phone</span>
+ <input class="detail client-phone" name="phone" type="tel"
+ t-on-change="captureChange"
+ t-att-value="props.partner.phone || ''" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Barcode</span>
+ <input class="detail barcode" name="barcode" t-on-change="captureChange"
+ t-att-value="props.partner.barcode || ''" />
+ </div>
+ <div class="client-detail">
+ <span class="label">Tax ID</span>
+ <input class="detail vat" name="vat" t-on-change="captureChange"
+ t-att-value="props.partner.vat || ''" />
+ </div>
+ <div t-if="env.pos.pricelists.length gt 1" class="client-detail">
+ <span class="label">Pricelist</span>
+ <select class="detail needsclick" name="property_product_pricelist"
+ t-on-change="captureChange">
+ <t t-foreach="env.pos.pricelists" t-as="pricelist"
+ t-key="pricelist.id">
+ <option t-att-value="pricelist.id"
+ t-att-selected="props.partner.property_product_pricelist ? (pricelist.id === props.partner.property_product_pricelist[0] ? true : false) : false">
+ <t t-esc="pricelist.display_name" />
+ </option>
+ </t>
+ </select>
+ </div>
+ </div>
+ </div>
+ </section>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml
new file mode 100644
index 00000000..7693f08c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientLine" owl="1">
+ <tr t-attf-class="client-line {{highlight}}" t-att-data-id="props.partner.id"
+ t-on-click="trigger('click-client', {client: props.partner})">
+ <td>
+ <t t-esc="props.partner.name" />
+ <span t-if="highlight">
+ <br/><button class="edit-client-button" t-on-click.stop="trigger('click-edit')">EDIT</button>
+ </span>
+ </td>
+ <td t-if="!env.isMobile">
+ <t t-esc="props.partner.address" />
+ </td>
+ <td t-if="!env.isMobile" style="width: 130px;">
+ <t t-esc="props.partner.phone || ''" />
+ </td>
+ <td t-if="env.isMobile">
+ <t t-esc="props.partner.zip or ''" />
+ <span t-if="highlight"><br/></span>
+ </td>
+ <td>
+ <t t-esc="props.partner.email or ''" />
+ <span t-if="highlight"><br/></span>
+ </td>
+ </tr>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml
new file mode 100644
index 00000000..baefab13
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ClientListScreen" owl="1">
+ <div class="clientlist-screen screen" t-on-activate-edit-mode="activateEditMode">
+ <div class="screen-content">
+ <div class="top-content">
+ <div t-if="!state.detailIsShown &amp;&amp; !state.selectedClient" class="button new-customer" role="img" aria-label="Add a customer"
+ t-on-click="trigger('activate-edit-mode', { isNewClient: true })"
+ title="Add a customer">
+ <t t-if="!env.isMobile">
+ Create
+ </t>
+ <t t-else="">
+ <i class="fa fa-plus"></i>
+ </t>
+ </div>
+ <div t-if="isNextButtonVisible" t-on-click="clickNext"
+ class="button next highlight">
+ <t t-if="!env.isMobile">
+ <t t-esc="nextButton.text" />
+ </t>
+ <t t-else="">
+ <i t-if="nextButton.command === 'deselect'" class="fa fa-trash"></i>
+ <i t-if="nextButton.command === 'set'" class="fa fa-check"></i>
+ </t>
+ </div>
+ <div class="button" t-if="state.detailIsShown" t-on-click="trigger('click-save')">
+ <t t-if="!env.isMobile">
+ <i class="fa fa-floppy-o"/>
+ <span> Save</span>
+ </t>
+ <t t-else="">
+ <i class="fa fa-floppy-o"/>
+ </t>
+ </div>
+ <div class="button back" t-on-click="back">
+ <t t-if="!env.isMobile">Discard</t>
+ <t t-else="">
+ <i class="fa fa-undo"></i>
+ </t>
+ </div>
+ <div t-if="!state.detailIsShown" class="searchbox-client top-content-center">
+ <input placeholder="Search Customers" size="1" t-on-keyup="updateClientList" />
+ <span class="search-clear-client"></span>
+ </div>
+ </div>
+ <section class="full-content">
+ <div class="client-window">
+ <section class="subwindow collapsed">
+ <div class="subwindow-container collapsed">
+ <div t-if="state.detailIsShown" class="client-details-contents subwindow-container-fix">
+ <ClientDetailsEdit t-props="state.editModeProps"
+ t-on-cancel-edit="cancelEdit"/>
+ </div>
+ </div>
+ </section>
+ <section class="subwindow list">
+ <div class="subwindow-container">
+ <div t-if="!state.detailIsShown" class="subwindow-container-fix scrollable-y">
+ <table class="client-list">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th t-if="!env.isMobile">Address</th>
+ <th t-if="!env.isMobile">Phone</th>
+ <th t-if="env.isMobile">ZIP</th>
+ <th>Email</th>
+ </tr>
+ </thead>
+ <tbody class="client-list-contents">
+ <t t-foreach="clients" t-as="partner"
+ t-key="partner.id">
+ <ClientLine partner="partner"
+ selectedClient="state.selectedClient"
+ detailIsShown="state.detailIsShown"
+ t-on-click-client="clickClient" />
+ </t>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </section>
+ </div>
+ </section>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml
new file mode 100644
index 00000000..72c85188
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="InvoiceButton" owl="1">
+ <div class="control-button" t-att-class="{ highlight: isHighlighted }">
+ <i class="fa fa-file-pdf-o"></i>
+ <span> </span>
+ <span><t t-esc="commandName"></t></span>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml
new file mode 100644
index 00000000..df3e4e06
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ReprintReceiptButton" owl="1">
+ <div class="control-button">
+ <i class="fa fa-print"></i>
+ <span> </span>
+ <span>Print Receipt</span>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml
new file mode 100644
index 00000000..479b35a4
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <div t-name="MobileOrderManagementScreen" class="screen-full-width" owl="1">
+ <div t-if="mobileState.showDetails" class="leftpane">
+ <OrderDetails order="orderManagementContext.selectedOrder" />
+ <div class="pads">
+ <div class="control-buttons">
+ <t t-foreach="controlButtons" t-as="cb" t-key="cb.name">
+ <t t-component="cb.component" t-key="cb.name" />
+ </t>
+ </div>
+ <div class="subpads">
+ <ActionpadWidget client="selectedClient" />
+ <NumpadWidget />
+ </div>
+ </div>
+ <div class="back-to-list" t-on-click="mobileState.showDetails = false">
+ <span>Back to list</span>
+ </div>
+ </div>
+ <div t-else="" class="rightpane">
+ <div class="flex-container">
+ <OrderManagementControlPanel />
+ <OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" />
+ </div>
+ </div>
+ </div>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml
new file mode 100644
index 00000000..87579d09
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderDetails" owl="1">
+ <div class="order-container">
+ <div t-ref="scrollable" class="order-scroller touch-scrollable">
+ <div class="order">
+ <t t-if="!props.order">
+ <div class="order-empty">
+ <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
+ title="Shopping cart" />
+ <h1>Select an order</h1>
+ </div>
+ </t>
+ <t t-elif="orderlines.length === 0">
+ <div class="order-empty">
+ <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
+ title="Shopping cart" />
+ <h1>Order is empty</h1>
+ </div>
+ </t>
+ <t t-else="">
+ <ul class="orderlines">
+ <t t-foreach="orderlines" t-as="orderline" t-key="orderline.id">
+ <OrderlineDetails line="orderline" />
+ </t>
+ </ul>
+ <OrderSummary total="total" tax="tax" />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml
new file mode 100644
index 00000000..865f609b
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderList" owl="1">
+ <div class="orders">
+ <div class="order-row header">
+ <div class="header name">Order</div>
+ <div class="header date">Date</div>
+ <div class="header customer">Customer</div>
+ <div class="header total">Total</div>
+ </div>
+ <div class="order-list">
+ <t t-foreach="props.orders" t-as="order" t-key="order.cid">
+ <OrderRow order="order" highlightedOrder="highlightedOrder" />
+ </t>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml
new file mode 100644
index 00000000..3a294bfd
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderManagementControlPanel" owl="1">
+ <div class="control-panel">
+ <div class="item button back" t-on-click="trigger('close-screen')">
+ <i class="fa fa-angle-double-left"></i>
+ <span> Back</span>
+ </div>
+ <div class="item search-box">
+ <span class="icon">
+ <i class="fa fa-search" />
+ </span>
+ <input type="text" t-model="orderManagementContext.searchString" t-on-keydown="onInputKeydown" placeholder="E.g. customer: Steward, date: 2020-05-09" />
+ <span class="clear" t-on-click="trigger('clear-search')">
+ <i class="fa fa-remove" />
+ </span>
+ </div>
+ <div t-if="showPageControls" class="item">
+ <div class="page-controls">
+ <div class="previous" t-on-click="trigger('prev-page')">
+ <i class="fa fa-fw fa-caret-left" role="img" aria-label="Previous Order List" title="Previous Order List"></i>
+ </div>
+ <div class="next" t-on-click="trigger('next-page')">
+ <i class="fa fa-fw fa-caret-right" role="img" aria-label="Next Order List" title="Next Order List"></i>
+ </div>
+ </div>
+ <div class="page">
+ <span><t t-esc="pageNumber" /></span>
+ </div>
+ </div>
+ <div t-else="" class="item"></div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml
new file mode 100644
index 00000000..8992e2c8
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderManagementScreen" owl="1">
+ <div class="order-management-screen screen" t-att-class="{ oe_hidden: !props.isShown }">
+ <div t-if="!env.isMobile" class="screen-full-width">
+ <div class="leftpane">
+ <OrderDetails order="orderManagementContext.selectedOrder" />
+ <div class="pads">
+ <div class="control-buttons">
+ <t t-foreach="controlButtons" t-as="cb" t-key="cb.name">
+ <t t-component="cb.component" t-key="cb.name" />
+ </t>
+ </div>
+ <div class="subpads">
+ <ActionpadWidget client="selectedClient" />
+ <NumpadWidget />
+ </div>
+ </div>
+ </div>
+ <div class="rightpane">
+ <div class="flex-container">
+ <OrderManagementControlPanel />
+ <OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" />
+ </div>
+ </div>
+ </div>
+ <MobileOrderManagementScreen t-else="" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml
new file mode 100644
index 00000000..29b07cfe
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderRow" owl="1">
+ <div class="order-row"
+ t-att-class="{ highlight: highlighted, lighter: !props.order.locked }"
+ t-on-click="trigger('click-order', props.order)">
+ <div class="item name"><t t-esc="name" /></div>
+ <div class="item date"><t t-esc="date" /></div>
+ <div class="item customer"><t t-esc="customer" /></div>
+ <div class="item total"><t t-esc="total" /></div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml
new file mode 100644
index 00000000..2e6869e5
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderlineDetails" owl="1">
+ <li class="orderline">
+ <span class="product-name">
+ <t t-esc="productName" />
+ </span>
+ <span class="price">
+ <t t-esc="totalPrice" />
+ </span>
+ <li class="info">
+ <strong>
+ <t t-esc="quantity" />
+ </strong>
+ <span><t t-esc="pricePerUnit" /></span>
+ </li>
+ </li>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml
new file mode 100644
index 00000000..0a80a0e0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ReprintReceiptScreen" owl="1">
+ <div class="receipt-screen screen">
+ <div class="screen-content">
+ <div class="top-content">
+ <span class="button back" t-on-click="confirm">
+ <i class="fa fa-angle-double-left"></i>
+ <span> </span>
+ <span>Back</span>
+ </span>
+ </div>
+ <div class="centered-content">
+ <div class="button print" t-on-click="tryReprint">
+ <i class="fa fa-print"></i> Print Receipt
+ </div>
+ <div class="pos-receipt-container">
+ <OrderReceipt order="props.order" t-ref="order-receipt" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml
new file mode 100644
index 00000000..381cd88c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PSNumpadInputButton" owl="1">
+ <button t-attf-class="{{ _class }}"
+ t-on-click="trigger('input-from-numpad', { key: props.value })">
+ <t t-slot="default">
+ <t t-if="props.text">
+ <t t-esc="props.text" />
+ </t>
+ <t t-else="">
+ <t t-esc="props.value" />
+ </t>
+ </t>
+ </button>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml
new file mode 100644
index 00000000..dacf7e96
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentMethodButton" owl="1">
+ <div class="button paymentmethod"
+ t-on-click="trigger('new-payment-line', props.paymentMethod)">
+ <div class="payment-name">
+ <t t-esc="props.paymentMethod.name" />
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml
new file mode 100644
index 00000000..d78128c9
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreen" owl="1">
+ <div class="payment-screen screen" t-att-class="{ oe_hidden: !props.isShown }">
+ <div class="screen-content">
+ <t t-if="!env.isMobile">
+ <div class="top-content">
+ <div class="button back"
+ t-on-click="showScreen('ProductScreen')">
+ <i class="fa fa-angle-double-left fa-fw"></i>
+ <span class="back_text">Back</span>
+ </div>
+ <div class="top-content-center"><h1>Payment</h1></div>
+ <div class="button next" t-att-class="{ highlight: currentOrder.is_paid() }"
+ t-on-click="validateOrder(false)">
+ <span class="next_text">Validate</span>
+ <i class="fa fa-angle-double-right fa-fw"></i>
+ </div>
+ </div>
+ </t>
+ <div class="main-content">
+ <div class="left-content">
+ <t t-if="env.isMobile">
+ <section class="paymentlines-container">
+ <PaymentScreenStatus paymentLines="paymentLines" />
+ </section>
+ </t>
+ <div class="paymentmethods-container">
+ <PaymentScreenPaymentLines paymentLines="paymentLines" />
+ <div class="paymentmethods">
+ <t t-foreach="payment_methods_from_config" t-as="paymentMethod"
+ t-key="paymentMethod.id">
+ <PaymentMethodButton paymentMethod="paymentMethod" />
+ </t>
+ </div>
+ </div>
+ </div>
+ <div class="right-content">
+ <t t-if="!env.isMobile">
+ <section class="paymentlines-container">
+ <PaymentScreenStatus paymentLines="paymentLines" />
+ </section>
+ </t>
+
+ <div class="payment-buttons-container">
+ <section class="payment-numpad">
+ <PaymentScreenNumpad />
+ </section>
+
+ <div class="payment-buttons">
+ <div class="customer-button">
+ <div class="button" t-on-click="selectClient">
+ <i class="fa fa-user" role="img" aria-label="Customer"
+ title="Customer" />
+ <span class="js_customer_name">
+ <t t-if="env.pos.get_client()">
+ <t t-esc="env.pos.get_client().name" />
+ </t>
+ <t t-if="!env.pos.get_client()">
+ Customer
+ </t>
+ </span>
+ </div>
+ </div>
+ <div class="payment-controls">
+ <div t-if="env.pos.config.module_account" class="button js_invoice"
+ t-att-class="{ highlight: currentOrder.is_to_invoice() }"
+ t-on-click="toggleIsToInvoice">
+ <i class="fa fa-file-text-o" /> Invoice
+ </div>
+ <div t-if="env.pos.config.tip_product_id" class="button js_tip"
+ t-on-click="addTip">
+ <i class="fa fa-heart" /> Tip
+ </div>
+ <div t-if="env.pos.config.iface_cashdrawer" class="button js_cashdrawer"
+ t-on-click="openCashbox">
+ <i class="fa fa-archive" /> Open Cashbox
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <t t-if="env.isMobile">
+ <div class="switchpane">
+ <button class="btn-switchpane" t-att-class="{ secondary: !currentOrder.is_paid() }" t-on-click="validateOrder(false)">
+ <h1>Validate</h1>
+ </button>
+ <button class="btn-switchpane secondary" t-on-click="showScreen('ProductScreen', {mobile_pane: 'left'})">
+ <h1>Review</h1>
+ </button>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml
new file mode 100644
index 00000000..792c490c
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreenElectronicPayment" owl="1">
+ <div class="paymentline electronic_payment">
+ <t t-if="props.line.payment_status == 'pending'">
+ <div>
+ Payment request pending
+ </div>
+ <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)">
+ Send
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'retry'">
+ <div>
+ Transaction cancelled
+ </div>
+ <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)">
+ Retry
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'force_done'">
+ <div>
+ Connection error
+ </div>
+ <div class="button send_force_done" title="Force Done" t-on-click="trigger('send-force-done', props.line)">
+ Force done
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'waitingCard'">
+ <div>
+ Waiting for card
+ </div>
+ <div class="button send_payment_cancel" title="Cancel Payment Request" t-on-click="trigger('send-payment-cancel', props.line)">
+ Cancel
+ </div>
+ </t>
+ <t t-elif="['waiting', 'waitingCancel'].includes(props.line.payment_status)">
+ <div>
+ Request sent
+ </div>
+ <div>
+ <i class="fa fa-spinner fa-spin" role="img" />
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'reversing'">
+ <div>
+ Reversal request sent to terminal
+ </div>
+ <div>
+ <i class="fa fa-spinner fa-spin" role="img" />
+ </div>
+ </t>
+ <t t-elif="props.line.payment_status == 'done'">
+ <div>
+ Payment Successful
+ </div>
+ <t t-if="props.line.can_be_reversed">
+ <div class="button send_payment_reversal" title="Reverse Payment" t-on-click="trigger('send-payment-reverse', props.line)">
+ Reverse
+ </div>
+ </t>
+ <t t-else="">
+ <div></div>
+ </t>
+ </t>
+ <t t-elif="props.line.payment_status == 'reversed'">
+ <div>
+ Payment reversed
+ </div>
+ <div></div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml
new file mode 100644
index 00000000..d988ab5f
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreenNumpad" owl="1">
+ <div class="numpad">
+ <PSNumpadInputButton value="'1'" />
+ <PSNumpadInputButton value="'2'" />
+ <PSNumpadInputButton value="'3'" />
+ <PSNumpadInputButton value="'+10'" changeClassTo="'mode-button'" />
+ <br />
+ <PSNumpadInputButton value="'4'" />
+ <PSNumpadInputButton value="'5'" />
+ <PSNumpadInputButton value="'6'" />
+ <PSNumpadInputButton value="'+20'" changeClassTo="'mode-button'" />
+ <br />
+ <PSNumpadInputButton value="'7'" />
+ <PSNumpadInputButton value="'8'" />
+ <PSNumpadInputButton value="'9'" />
+ <PSNumpadInputButton value="'+50'" changeClassTo="'mode-button'" />
+ <br />
+ <PSNumpadInputButton value="'-'" text="'+/-'" />
+ <PSNumpadInputButton value="'0'" />
+ <PSNumpadInputButton value="decimalPoint" />
+ <PSNumpadInputButton value="'Backspace'">
+ <img src="/point_of_sale/static/src/img/backspace.png" width="24" height="21"
+ alt="Backspace" />
+ </PSNumpadInputButton>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml
new file mode 100644
index 00000000..6816f300
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="PaymentScreenPaymentLines" owl="1">
+ <div class="paymentlines">
+ <t t-foreach="props.paymentLines" t-as="line" t-key="line.cid">
+ <t t-if="line.selected">
+ <div class="paymentline selected"
+ t-att-class="selectedLineClass(line)"
+ t-on-click="trigger('select-payment-line', { cid: line.cid })">
+ <div class="payment-name">
+ <t t-esc="line.payment_method.name" />
+ </div>
+ <div class="payment-amount">
+ <t t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)">
+ <t t-esc="env.pos.format_currency_no_symbol(line.get_amount())" />
+ </t>
+ <t t-else="">
+ <t t-esc="formatLineAmount(line)" />
+ </t>
+ </div>
+ <t t-if="!line.payment_status or !['done', 'reversed'].includes(line.payment_status)">
+ <div class="delete-button"
+ t-on-click="trigger('delete-payment-line', { cid: line.cid })"
+ aria-label="Delete" title="Delete">
+ <i class="fa fa-times-circle" />
+ </div>
+ </t>
+ </div>
+ <t t-if="line and line.payment_status">
+ <PaymentScreenElectronicPayment line="line" />
+ </t>
+ </t>
+ <t t-else="">
+ <div class="paymentline"
+ t-att-class="unselectedLineClass(line)"
+ t-on-click="trigger('select-payment-line', { cid: line.cid })">
+ <div class="payment-name">
+ <t t-esc="line.payment_method.name" />
+ </div>
+ <div class="payment-amount">
+ <t t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)">
+ <t t-esc="env.pos.format_currency_no_symbol(line.get_amount())" />
+ </t>
+ <t t-else="">
+ <t t-esc="formatLineAmount(line)" />
+ </t>
+ </div>
+ <t t-if="!line.payment_status or !['done', 'reversed'].includes(line.payment_status)">
+ <div class="delete-button"
+ t-on-click="trigger('delete-payment-line', { cid: line.cid })"
+ aria-label="Delete" title="Delete">
+ <i class="fa fa-times-circle" />
+ </div>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml
new file mode 100644
index 00000000..7c90c8a5
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+<t t-name="PaymentScreenStatus" owl="1">
+ <div t-if="props.paymentLines.length === 0" class="paymentlines-empty">
+ <div class="total">
+ <t t-esc="totalDueText" />
+ </div>
+ <div class="message">
+ Please select a payment method.
+ </div>
+ </div>
+
+ <div t-else="">
+ <div class="payment-status-container">
+ <div>
+ <div class="payment-status-remaining">
+ <span class="label">Remaining</span>
+ <span class="amount"
+ t-att-class="{ highlight: currentOrder.get_due() > 0 }">
+ <t t-esc="remainingText" />
+ </span>
+ </div>
+ <div class="payment-status-total-due">
+ <span class="label">Total Due</span>
+ <span>
+ <t t-esc="totalDueText" />
+ </span>
+ </div>
+ </div>
+ <div>
+ <div class="payment-status-change">
+ <span class="label">Change</span>
+ <span class="amount"
+ t-att-class="{ highlight: currentOrder.get_change() > 0 }">
+ <t t-esc="changeText" />
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+</t>
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml
new file mode 100644
index 00000000..183912fd
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ActionpadWidget" owl="1">
+ <div class="actionpad">
+ <button class="button set-customer" t-att-class="{'decentered': isLongName}"
+ t-on-click="trigger('click-customer')">
+ <t t-if="!env.isMobile"><i class="fa fa-user" role="img" aria-label="Customer" title="Customer" /></t>
+ <t t-if="client">
+ <t t-esc="client.name" />
+ </t>
+ <t t-else="">
+ Customer
+ </t>
+ </button>
+ <button class="button pay" t-on-click="trigger('click-pay')">
+ <div class="pay-circle">
+ <i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" />
+ </div>
+ Payment
+ </button>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml
new file mode 100644
index 00000000..27193bf9
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CashBoxOpening" owl="1">
+ <div style="margin-top: -20px;">
+ <br/>
+ <h1>
+ <span>
+ Pos closed.
+ </span><br/>
+ <span>
+ Set a cash opening
+ </span>
+
+ </h1>
+ <h1>Opening amount:</h1>
+ <input name="cashBoxValue" class="cashbox-input" t-on-change="captureChange" t-att-value="defaultValue"/>
+ <span class="currencyCashBox" t-esc="symbol"/>
+
+ <h1>Notes:</h1>
+ <textarea name="notes" style="width: 51%" t-on-change="captureChange"/><br/><br/>
+
+ <span class="control-button" t-on-click="startSession()">Open</span>
+
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml
new file mode 100644
index 00000000..0e9ba155
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CategoryBreadcrumb" owl="1">
+ <span class="breadcrumb">
+ <img src="/point_of_sale/static/src/img/bc-arrow-big.png" class="breadcrumb-arrow"
+ alt="Slash" />
+ <span class="breadcrumb-button"
+ t-on-click="trigger('switch-category', props.category.id)">
+ <t t-esc="props.category.name"></t>
+ </span>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml
new file mode 100644
index 00000000..da829cba
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CategoryButton" owl="1">
+ <span class="category-button" t-on-click="trigger('switch-category', props.category.id)">
+ <div class="category-img">
+ <img t-att-src="imageUrl" alt="Category" />
+ </div>
+ <div class="category-name">
+ <t t-esc="props.category.name" />
+ </div>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml
new file mode 100644
index 00000000..de052e16
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="CategorySimpleButton" owl="1">
+ <span class="category-simple-button"
+ t-on-click="trigger('switch-category', props.category.id)">
+ <t t-esc="props.category.name" />
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml
new file mode 100644
index 00000000..ab09f363
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SetFiscalPositionButton" owl="1">
+ <div class="control-button o_fiscal_position_button">
+ <i class="fa fa-book" role="img" aria-label="Set fiscal position"
+ title="Set fiscal position" />
+ <t t-esc='currentFiscalPositionName' />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml
new file mode 100644
index 00000000..ffe3e3ec
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="SetPricelistButton" owl="1">
+ <div class="control-button o_pricelist_button">
+ <i class="fa fa-th-list" role="img" aria-label="Price list" title="Price list" />
+ <t t-esc="currentPricelistName" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml
new file mode 100644
index 00000000..2bfa426e
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="HomeCategoryBreadcrumb" owl="1">
+ <span class="breadcrumb">
+ <span t-if="!env.isMobile" class="breadcrumb-button breadcrumb-home"
+ t-on-click="trigger('switch-category', 0)">
+ <i class="fa fa-home" role="img" aria-label="Home" title="Home"></i>
+ </span>
+ <span t-if="env.isMobile" class="breadcrumb-button breadcrumb-home"
+ t-on-click="trigger('categ-popup', props.subcategories)">
+ <t t-if="env.pos.get('selectedCategoryId') === 0">
+ All
+ </t>
+ <t t-else="">
+ <t t-esc="props.currentCat.name"/>
+ </t>
+ </span>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml
new file mode 100644
index 00000000..4b9962c0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="NumpadWidget" owl="1">
+ <div class="numpad">
+ <button class="input-button number-char" t-on-click="sendInput('1')">1</button>
+ <button class="input-button number-char" t-on-click="sendInput('2')">2</button>
+ <button class="input-button number-char" t-on-click="sendInput('3')">3</button>
+ <button class="mode-button" t-att-class="{'selected-mode': props.activeMode === 'quantity'}"
+ t-on-click="changeMode('quantity')">Qty</button>
+ <br />
+ <button class="input-button number-char" t-on-click="sendInput('4')">4</button>
+ <button class="input-button number-char" t-on-click="sendInput('5')">5</button>
+ <button class="input-button number-char" t-on-click="sendInput('6')">6</button>
+ <button class="mode-button" t-att-class="{
+ 'selected-mode': props.activeMode === 'discount',
+ 'disabled-mode': !hasManualDiscount
+ }"
+ t-att-disabled="!hasManualDiscount"
+ t-on-click="changeMode('discount')">Disc</button>
+ <br />
+ <button class="input-button number-char" t-on-click="sendInput('7')">7</button>
+ <button class="input-button number-char" t-on-click="sendInput('8')">8</button>
+ <button class="input-button number-char" t-on-click="sendInput('9')">9</button>
+ <button class="mode-button" t-att-class="{
+ 'selected-mode': props.activeMode === 'price',
+ 'disabled-mode': !hasPriceControlRights
+ }" t-att-disabled="!hasPriceControlRights"
+ t-on-click="changeMode('price')">Price</button>
+ <br />
+ <button class="input-button numpad-minus" t-on-click="sendInput('-')">+/-</button>
+ <button class="input-button number-char" t-on-click="sendInput('0')">0</button>
+ <button class="input-button number-char" t-on-click="sendInput(decimalSeparator)">
+ <t t-esc="decimalSeparator" />
+ </button>
+ <button class="input-button numpad-backspace" t-on-click="sendInput('Backspace')">
+ <img style="pointer-events: none;" src="/point_of_sale/static/src/img/backspace.png"
+ width="24" height="21" alt="Backspace" />
+ </button>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml
new file mode 100644
index 00000000..a229c53a
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderSummary" owl="1">
+ <div class="summary clearfix">
+ <div class="line">
+ <div class="entry total">
+ <span class="badge">Total: </span>
+ <span class="value">
+ <t t-esc="props.total" />
+ </span>
+ <div t-if="props.tax" class="subentry">
+ Taxes:
+ <span class="value">
+ <t t-esc="props.tax" />
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml
new file mode 100644
index 00000000..532309dc
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderWidget" owl="1">
+ <div class="order-container" t-ref="scrollable">
+ <div class="order">
+ <t t-if="orderlinesArray.length === 0" >
+ <div class='order-empty'>
+ <i class='fa fa-shopping-cart' role="img" aria-label="Shopping cart"
+ title="Shopping cart"/>
+ <h1>This order is empty</h1>
+ </div>
+ </t>
+ <t t-else="">
+ <ul class="orderlines">
+ <t t-foreach="orderlinesArray" t-as="orderline" t-key="orderline.id">
+ <Orderline line="orderline" />
+ </t>
+ </ul>
+ <OrderSummary total="state.total" tax="state.tax" />
+ </t>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
new file mode 100644
index 00000000..e4ede636
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="Orderline" owl="1">
+ <li t-on-click="selectLine" class="orderline" t-att-class="addedClasses">
+ <span class="product-name">
+ <t t-esc="props.line.get_full_product_name()"/>
+ <span> </span>
+ <t t-if="props.line.get_product().tracking!=='none' &amp;&amp; (env.pos.picking_type.use_create_lots || env.pos.picking_type.use_existing_lots)">
+ <t t-if="props.line.has_valid_product_lot()">
+ <i t-on-click.stop="lotIconClicked"
+ class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_green"
+ aria-label="Valid product lot"
+ role="img"
+ title="Valid product lot"
+ />
+ </t>
+ <t t-else="">
+ <i t-on-click.stop="lotIconClicked"
+ class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_red"
+ aria-label="Invalid product lot"
+ role="img"
+ title="Invalid product lot"
+ />
+ </t>
+ </t>
+ </span>
+ <span class="price">
+ <t t-esc="env.pos.format_currency(props.line.get_display_price())"/>
+ </span>
+ <ul class="info-list">
+ <t t-if="props.line.get_quantity_str() !== '1' || props.line.selected ">
+ <li class="info">
+ <em>
+ <t t-esc="props.line.get_quantity_str()" />
+ </em>
+ <span> </span><t t-esc="props.line.get_unit().name" />
+ at
+ <t t-if="props.line.display_discount_policy() == 'without_discount' and
+ props.line.get_unit_display_price() &lt; props.line.get_lst_price()">
+ <s>
+ <t t-esc="env.pos.format_currency(props.line.get_fixed_lst_price(),'Product Price')" />
+ </s>
+ <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" />
+ </t>
+ <t t-else="">
+ <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" />
+ </t>
+ /
+ <t t-esc="props.line.get_unit().name" />
+ </li>
+ </t>
+ <t t-if="props.line.get_discount_str() !== '0'">
+ <li class="info">
+ With a
+ <em>
+ <t t-esc="props.line.get_discount_str()" />%
+ </em>
+ discount
+ </li>
+ </t>
+ </ul>
+ <t t-if="props.line.get_lot_lines()">
+ <ul class="info-list">
+ <t t-foreach="props.line.get_lot_lines()" t-as="lot" t-key="lot.cid">
+ <li>
+ SN <t t-esc="lot.attributes['lot_name']"/>
+ </li>
+ </t>
+ </ul>
+ </t>
+ </li>
+ </t>
+
+</templates>
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 00000000..4825efaf
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductItem" owl="1">
+ <article class="product" tabindex="0" t-on-keypress="spaceClickProduct"
+ t-on-click="trigger('click-product', props.product)"
+ t-att-data-product-id="props.product.id"
+ t-attf-aria-labelledby="article_product_{{props.product.id}}">
+ <div class="product-img">
+ <img t-att-src="imageUrl" t-att-alt="props.product.display_name" />
+ <span class="price-tag">
+ <t t-esc="price" />
+ </span>
+ </div>
+ <div class="product-name" t-attf-id="article_product_{{props.product.id}}">
+ <t t-esc="props.product.display_name" />
+ </div>
+ </article>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml
new file mode 100644
index 00000000..9e87ca95
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductList" owl="1">
+ <div class="product-list-container">
+ <div t-if="props.products.length != 0" class="product-list">
+ <t t-foreach="props.products" t-as="product" t-key="product.id">
+ <ProductItem product="product" />
+ </t>
+ </div>
+ <div t-else="" class="product-list-empty">
+ <div class="product-list-empty">
+ <t t-if="props.searchWord !== ''">
+ <p>
+ No results found for "
+ <b t-esc="props.searchWord"></b>
+ ".
+ </p>
+ </t>
+ <t t-else="">
+ <p>There are no products in this category.</p>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml
new file mode 100644
index 00000000..6bef9281
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductScreen" owl="1">
+ <div class="product-screen screen" t-att-class="{ oe_hidden: !props.isShown }">
+ <div class="screen-full-width">
+ <div class="leftpane" t-if="!env.isMobile || mobile_pane === 'left'">
+ <t t-if="state.cashControl">
+ <CashBoxOpening cashControl="state"/>
+ </t>
+ <t t-else="">
+ <OrderWidget/>
+ <div class="pads">
+ <div class="control-buttons">
+ <t t-foreach="controlButtons" t-as="cb" t-key="cb.name">
+ <t t-component="cb.component" t-key="cb.name" />
+ </t>
+ </div>
+ <div class="subpads">
+ <ActionpadWidget client="client"/>
+ <NumpadWidget activeMode="state.numpadMode" />
+ </div>
+ </div>
+ <t t-if="env.isMobile">
+ <MobileOrderWidget pane="mobile_pane" t-on-switchpane="switchPane"/>
+ </t>
+ </t>
+ </div>
+ <div class="rightpane" t-if="!env.isMobile || mobile_pane === 'right'">
+ <ProductsWidget t-if="!state.cashControl"/>
+ <t t-if="env.isMobile">
+ <MobileOrderWidget pane="mobile_pane" t-on-switchpane="switchPane"/>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml
new file mode 100644
index 00000000..3dfd5276
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductsWidget" owl="1">
+ <div class="products-widget">
+ <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" hasNoCategories="hasNoCategories" />
+ <ProductList products="productsToDisplay" searchWord="searchWord" />
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml
new file mode 100644
index 00000000..2c7d7727
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductsWidgetControlPanel" owl="1">
+ <div class="products-widget-control">
+ <t t-if="!props.hasNoCategories">
+ <div class="rightpane-header" t-att-class="{
+ 'green-border-bottom': !env.pos.config.iface_display_categ_images,
+ 'grey-border-bottom': env.pos.config.iface_display_categ_images,
+ }">
+ <!-- Breadcrumbs -->
+ <div class="breadcrumbs">
+ <HomeCategoryBreadcrumb subcategories="props.subcategories" currentCat="props.breadcrumbs[props.breadcrumbs.length - 1]"/>
+ <t t-if="!env.isMobile">
+ <t t-foreach="props.breadcrumbs" t-as="category" t-key="category.id">
+ <CategoryBreadcrumb category="category" />
+ </t>
+ </t>
+ </div>
+ <!-- Subcategories -->
+ <t t-if="props.subcategories.length > 0 and !env.pos.config.iface_display_categ_images and !env.isMobile">
+ <t t-foreach="props.subcategories" t-as="category" t-key="category.id">
+ <CategorySimpleButton category="category" />
+ </t>
+ </t>
+ </div>
+ <t t-if="props.subcategories.length > 0 and env.pos.config.iface_display_categ_images and !env.isMobile">
+ <div class="categories">
+ <div class="category-list-scroller">
+ <div class="category-list">
+ <t t-foreach="props.subcategories" t-as="category" t-key="category.id">
+ <CategoryButton category="category" />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ </t>
+ <Portal target="'.pos .search-bar-portal'">
+ <div class="search-box">
+ <span class="icon"><i class="fa fa-search"></i></span>
+ <span t-on-click="clearSearch" class="clear-icon">
+ <i class="fa fa-times" aria-hidden="true"></i>
+ </span>
+ <input t-ref="search-word-input" type="text" placeholder="Search Products..." t-on-keyup="updateSearch" />
+ </div>
+ </Portal>
+ </div>
+ </t>
+
+</templates>
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 00000000..379b360d
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml
@@ -0,0 +1,211 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="OrderReceipt" owl="1">
+ <div class="pos-receipt">
+ <t t-if="receipt.company.logo">
+ <img class="pos-receipt-logo" t-att-src="receipt.company.logo" alt="Logo"/>
+ <br/>
+ </t>
+ <t t-if="!receipt.company.logo">
+ <h2 class="pos-receipt-center-align">
+ <t t-esc="receipt.company.name" />
+ </h2>
+ <br/>
+ </t>
+ <div class="pos-receipt-contact">
+ <t t-if="receipt.company.contact_address">
+ <div><t t-esc="receipt.company.contact_address" /></div>
+ </t>
+ <t t-if="receipt.company.phone">
+ <div>Tel:<t t-esc="receipt.company.phone" /></div>
+ </t>
+ <t t-if="receipt.company.vat">
+ <div><t t-esc="receipt.company.vat_label"/>:<t t-esc="receipt.company.vat" /></div>
+ </t>
+ <t t-if="receipt.company.email">
+ <div><t t-esc="receipt.company.email" /></div>
+ </t>
+ <t t-if="receipt.company.website">
+ <div><t t-esc="receipt.company.website" /></div>
+ </t>
+ <t t-if="receipt.header_html">
+ <t t-raw="receipt.header_html" />
+ </t>
+ <t t-if="!receipt.header_html and receipt.header">
+ <div style="white-space:pre-line"><t t-esc="receipt.header" /></div>
+ </t>
+ <t t-if="receipt.cashier">
+ <div class="cashier">
+ <div>--------------------------------</div>
+ <div>Served by <t t-esc="receipt.cashier" /></div>
+ </div>
+ </t>
+ </div>
+ <br /><br />
+
+ <!-- Orderlines -->
+
+ <div class="orderlines">
+ <t t-foreach="receipt.orderlines" t-as="line" t-key="line.id">
+ <t t-if="isSimple(line)">
+ <div>
+ <t t-esc="line.product_name_wrapped[0]" />
+ <span t-esc="env.pos.format_currency_no_symbol(line.price_display)" class="price_display pos-receipt-right-align"/>
+ </div>
+ <WrappedProductNameLines line="line" />
+ </t>
+ <t t-else="">
+ <div t-esc="line.product_name_wrapped[0]" />
+ <WrappedProductNameLines line="line" />
+ <t t-if="line.display_discount_policy == 'without_discount' and line.price != line.price_lst">
+ <div class="pos-receipt-left-padding">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_lst)" />
+ ->
+ <t t-esc="env.pos.format_currency_no_symbol(line.price)" />
+ </div>
+ </t>
+ <t t-elif="line.discount !== 0">
+ <div class="pos-receipt-left-padding">
+ <t t-if="env.pos.config.iface_tax_included === 'total'">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_with_tax_before_discount)"/>
+ </t>
+ <t t-else="">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price)"/>
+ </t>
+ </div>
+ </t>
+ <t t-if="line.discount !== 0">
+ <div class="pos-receipt-left-padding">
+ Discount: <t t-esc="line.discount" />%
+ </div>
+ </t>
+ <div class="pos-receipt-left-padding">
+ <t t-esc="Math.round(line.quantity * Math.pow(10, env.pos.dp['Product Unit of Measure'])) / Math.pow(10, env.pos.dp['Product Unit of Measure'])"/>
+ <t t-if="!line.is_in_unit" t-esc="line.unit_name" />
+ x
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_display_one)" />
+ <span class="price_display pos-receipt-right-align">
+ <t t-esc="env.pos.format_currency_no_symbol(line.price_display)" />
+ </span>
+ </div>
+ </t>
+ <t t-if="line.pack_lot_lines">
+ <div class="pos-receipt-left-padding">
+ <ul>
+ <t t-foreach="line.pack_lot_lines" t-as="lot" t-key="lot.cid">
+ <li>
+ SN <t t-esc="lot.attributes['lot_name']"/>
+ </li>
+ </t>
+ </ul>
+ </div>
+ </t>
+ </t>
+ </div>
+
+ <!-- Subtotal -->
+
+ <t t-if="!isTaxIncluded">
+ <div class="pos-receipt-right-align">--------</div>
+ <br/>
+ <div>Subtotal<span t-esc="env.pos.format_currency(receipt.subtotal)" class="pos-receipt-right-align"/></div>
+ <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name">
+ <div>
+ <t t-esc="tax.name" />
+ <span t-esc='env.pos.format_currency_no_symbol(tax.amount)' class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ </t>
+
+ <!-- Total -->
+ <div class="pos-receipt-right-align">--------</div>
+ <br/>
+ <div class="pos-receipt-amount">
+ TOTAL
+ <span t-esc="env.pos.format_currency(receipt.total_with_tax)" class="pos-receipt-right-align"/>
+ </div>
+ <t t-if="receipt.total_rounded != receipt.total_with_tax">
+ <div class="pos-receipt-amount">
+ Rounding
+ <span t-esc='env.pos.format_currency(receipt.rounding_applied)' class="pos-receipt-right-align"/>
+ </div>
+ <div class="pos-receipt-amount">
+ To Pay
+ <span t-esc='env.pos.format_currency(receipt.total_rounded)' class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <br/><br/>
+
+ <!-- Payment Lines -->
+
+ <t t-foreach="receipt.paymentlines" t-as="line" t-key="line.cid">
+ <div>
+ <t t-esc="line.name" />
+ <span t-esc="env.pos.format_currency_no_symbol(line.amount)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <br/>
+
+ <div class="pos-receipt-amount receipt-change">
+ CHANGE
+ <span t-esc="env.pos.format_currency(receipt.change)" class="pos-receipt-right-align"/>
+ </div>
+ <br/>
+
+ <!-- Extra Payment Info -->
+
+ <t t-if="receipt.total_discount">
+ <div>
+ Discounts
+ <span t-esc="env.pos.format_currency(receipt.total_discount)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <t t-if="isTaxIncluded">
+ <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name">
+ <div>
+ <t t-esc="tax.name" />
+ <span t-esc="env.pos.format_currency_no_symbol(tax.amount)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+ <div>
+ Total Taxes
+ <span t-esc="env.pos.format_currency(receipt.total_tax)" class="pos-receipt-right-align"/>
+ </div>
+ </t>
+
+ <div class="before-footer" />
+
+ <!-- Footer -->
+ <div t-if="receipt.footer_html" class="pos-receipt-center-align">
+ <t t-raw="receipt.footer_html" />
+ </div>
+
+ <div t-if="!receipt.footer_html and receipt.footer" class="pos-receipt-center-align" style="white-space:pre-line">
+ <br/>
+ <t t-esc="receipt.footer" />
+ <br/>
+ <br/>
+ </div>
+
+ <div class="after-footer">
+ <t t-foreach="receipt.paymentlines" t-as="line">
+ <t t-if="line.ticket">
+ <br />
+ <div class="pos-payment-terminal-receipt">
+ <t t-raw="line.ticket" />
+ </div>
+ </t>
+ </t>
+ </div>
+
+ <br/>
+ <div class="pos-receipt-order-data">
+ <div><t t-esc="receipt.name" /></div>
+ <div><t t-esc="receipt.date.localestring" /></div>
+ </div>
+
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml
new file mode 100644
index 00000000..8f0bd54a
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ReceiptScreen" owl="1">
+ <div class="receipt-screen screen">
+ <div class="screen-content">
+ <div class="top-content">
+ <div class="top-content-center">
+ <h1 t-if="!env.isMobile">
+ <t t-esc="orderAmountPlusTip" />
+ </h1>
+ </div>
+ <div class="button next" t-att-class="{ highlight: !locked }"
+ t-on-click="orderDone">
+ New Order <i class="fa fa-angle-double-right"></i>
+ </div>
+ </div>
+ <div class="default-view">
+ <div class="pos-receipt-container">
+ <OrderReceipt order="currentOrder" t-ref="order-receipt" />
+ </div>
+ <div class="actions">
+ <h1>How would you like to receive your receipt?</h1>
+ <div class="buttons">
+ <div class="button print" t-on-click="printReceipt">
+ <i class="fa fa-print"></i> Print Receipt
+ </div>
+ </div>
+ <form t-on-submit.prevent="onSendEmail" class="send-email">
+ <div class="email-icon"><i class="fa fa-envelope-o" aria-hidden="true"></i></div>
+ <div class="input-email">
+ <input type="email" placeholder="Email Receipt" t-model="orderUiState.inputEmail" />
+ <button class="send" t-att-class="{ highlight: is_email(orderUiState.inputEmail) }" type="submit">Send</button>
+ </div>
+ </form>
+ <t t-if="orderUiState.emailSuccessful !== null">
+ <div class="notice" t-attf-class="{{ orderUiState.emailSuccessful ? 'successful' : 'failed' }}">
+ <t t-esc="orderUiState.emailNotice"></t>
+ </div>
+ </t>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml
new file mode 100644
index 00000000..d49061a8
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="WrappedProductNameLines" owl="1">
+ <span>
+ <t t-foreach="props.line.product_name_wrapped.slice(1)" t-as="wrapped_line"><t t-esc="wrapped_line"/></t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml
new file mode 100644
index 00000000..de21dcc3
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ScaleScreen" owl="1">
+ <div class="scale-screen screen">
+ <div class="screen-content">
+ <div class="top-content">
+ <span class="button back" t-on-click="back">
+ <i class="fa fa-angle-double-left"></i>
+ Back
+ </span>
+ <h1 class="product-name">
+ <t t-esc="productName" />
+ </h1>
+ </div>
+ <div class="centered-content">
+ <div class="weight js-weight">
+ <t t-esc="productWeightString" />
+ </div>
+ <div class="product-price">
+ <t
+ t-esc="env.pos.format_currency(productPrice) + '/' + productUom" />
+ </div>
+ <div class="computed-price">
+ <t t-esc="computedPriceString" />
+ </div>
+ <div class="buy-product" t-on-click="confirm">
+ Order
+ <i class="fa fa-angle-double-right"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml
new file mode 100644
index 00000000..40bfc501
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="TicketScreen" owl="1">
+ <div class="ticket-screen screen">
+ <div class="screen-content">
+ <div class="controls">
+ <div class="buttons">
+ <button t-if="showNewTicketButton" class="highlight" t-on-click="createNewOrder">New Order</button>
+ <button class="discard" t-on-click="trigger('close-screen')">Discard</button>
+ </div>
+ <t t-set="placeholder">Search Tickets...</t>
+ <SearchBar config="searchBarConfig" placeholder="placeholder" />
+ </div>
+ <div class="orders">
+ <div class="header-row">
+ <div class="col start wide">Date</div>
+ <div class="col start wide">Receipt Number</div>
+ <div class="col start">Customer</div>
+ <div class="col start wide" t-if="showCardholderName()">Cardholder Name</div>
+ <div class="col start">Employee</div>
+ <div class="col end">Total</div>
+ <div class="col start narrow">Status</div>
+ <div class="col center very-narrow" name="delete"></div>
+ </div>
+ <t t-foreach="filteredOrderList" t-as="order" t-key="order.cid">
+ <div class="order-row pointer" t-on-click="selectOrder(order)">
+ <div class="col start wide">
+ <t t-esc="getDate(order)"></t>
+ </div>
+ <div class="col start wide">
+ <t t-esc="order.name"></t>
+ </div>
+ <div class="col start">
+ <t t-esc="getCustomer(order)"></t>
+ </div>
+ <div t-if="showCardholderName()" class="col start">
+ <t t-esc="getCardholderName(order)"></t>
+ </div>
+ <div class="col start">
+ <t t-esc="getEmployee(order)"></t>
+ </div>
+ <div class="col end">
+ <t t-esc="getTotal(order)"></t>
+ </div>
+ <div class="col start narrow">
+ <t t-esc="getStatus(order)"></t>
+ </div>
+ <div t-if="!hideDeleteButton(order)" class="col center very-narrow delete-button" name="delete" t-on-click.stop="deleteOrder(order)">
+ <i class="fa fa-trash" aria-hidden="true"></i>
+ </div>
+ <div t-else="" class="col center very-narrow delete-button"></div>
+ </div>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
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 00000000..7ecc7cc1
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/debug_manager.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+<t t-extend="WebClient.DebugManager.Backend">
+ <t t-jquery="a[data-action='perform_click_everywhere_test']" t-operation="after">
+ <a role="menuitem" href="#" data-action="perform_pos_js_tests" class="dropdown-item">Run Point of Sale JS Tests</a>
+ </t>
+</t>
+
+</templates>