summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/static
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
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static')
-rw-r--r--addons/point_of_sale/static/description/icon.pngbin0 -> 6876 bytes
-rw-r--r--addons/point_of_sale/static/description/icon.svg1
-rw-r--r--addons/point_of_sale/static/img/barcode.pngbin0 -> 1292 bytes
-rw-r--r--addons/point_of_sale/static/img/desk_organizer.pngbin0 -> 46329 bytes
-rw-r--r--addons/point_of_sale/static/img/desk_pad.pngbin0 -> 17323 bytes
-rw-r--r--addons/point_of_sale/static/img/led_lamp.pngbin0 -> 17197 bytes
-rw-r--r--addons/point_of_sale/static/img/letter_tray.pngbin0 -> 42778 bytes
-rw-r--r--addons/point_of_sale/static/img/magnetic_board.pngbin0 -> 2424 bytes
-rw-r--r--addons/point_of_sale/static/img/monitor_stand.pngbin0 -> 16473 bytes
-rw-r--r--addons/point_of_sale/static/img/newspaper_stand.pngbin0 -> 22344 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/acsone.pngbin0 -> 24970 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/bhc.jpgbin0 -> 21618 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/camptocamp.pngbin0 -> 25879 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/datalp.jpgbin0 -> 53998 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/eezee-it.pngbin0 -> 29984 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/eggs-solutions.jpgbin0 -> 33740 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/ekomurz.gifbin0 -> 1820 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/openbig.jpgbin0 -> 18401 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/services.jpgbin0 -> 24486 bytes
-rw-r--r--addons/point_of_sale/static/img/partners/vauxoo.pngbin0 -> 9121 bytes
-rw-r--r--addons/point_of_sale/static/img/small_shelf.pngbin0 -> 26927 bytes
-rw-r--r--addons/point_of_sale/static/img/storage.pngbin0 -> 58953 bytes
-rw-r--r--addons/point_of_sale/static/img/wall_shelf_unit.pngbin0 -> 27040 bytes
-rw-r--r--addons/point_of_sale/static/img/whiteboard.pngbin0 -> 25939 bytes
-rw-r--r--addons/point_of_sale/static/img/whiteboard_pen.pngbin0 -> 45949 bytes
-rw-r--r--addons/point_of_sale/static/lib/backbone/backbone.js1581
-rw-r--r--addons/point_of_sale/static/lib/html2canvas.js2868
-rw-r--r--addons/point_of_sale/static/lib/sha1.js159
-rw-r--r--addons/point_of_sale/static/lib/waitfont.js59
-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
-rw-r--r--addons/point_of_sale/static/tests/tours/Chrome.tour.js103
-rw-r--r--addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js138
-rw-r--r--addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js70
-rw-r--r--addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js66
-rw-r--r--addons/point_of_sale/static/tests/tours/ProductScreen.tour.js105
-rw-r--r--addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js61
-rw-r--r--addons/point_of_sale/static/tests/tours/TicketScreen.tour.js54
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js42
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js57
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js23
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js30
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js72
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js180
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js215
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js77
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js254
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js79
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js39
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js107
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/utils.js153
-rw-r--r--addons/point_of_sale/static/tests/tours/point_of_sale.js436
-rw-r--r--addons/point_of_sale/static/tests/unit/helpers/test_env.js46
-rw-r--r--addons/point_of_sale/static/tests/unit/helpers/test_main.js23
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js89
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js414
-rw-r--r--addons/point_of_sale/static/tests/unit/test_NumberBuffer.js65
-rw-r--r--addons/point_of_sale/static/tests/unit/test_PaymentScreen.js309
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ProductScreen.js603
-rw-r--r--addons/point_of_sale/static/tests/unit/test_popups.js180
251 files changed, 28184 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/description/icon.png b/addons/point_of_sale/static/description/icon.png
new file mode 100644
index 00000000..13890ffe
--- /dev/null
+++ b/addons/point_of_sale/static/description/icon.png
Binary files differ
diff --git a/addons/point_of_sale/static/description/icon.svg b/addons/point_of_sale/static/description/icon.svg
new file mode 100644
index 00000000..bcc50ac6
--- /dev/null
+++ b/addons/point_of_sale/static/description/icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="98.616%"><stop offset="0%" stop-color="#797C79"/><stop offset="100%" stop-color="#545554"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M32.25 69H4c-2 0-4-1-4-4V39.181L19 20h32v6.208l1.992 12.632L51 41.123V50L32.25 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#000" fill-opacity=".3" d="M51 22H19v3.75h32V22zm2 18.75V37l-2-9.375H19L17 37v3.75h2V52h20V40.75h8V52h4V40.75h2zm-18 7.5H23v-7.5h12v7.5z"/><path fill="#FFF" d="M51 20H19v3.75h32V20zm2 18.75V35l-2-9.375H19L17 35v3.75h2V50h20V38.75h8V50h4V38.75h2zm-18 7.5H23v-7.5h12v7.5z"/></g></g></svg> \ No newline at end of file
diff --git a/addons/point_of_sale/static/img/barcode.png b/addons/point_of_sale/static/img/barcode.png
new file mode 100644
index 00000000..2e9d2f4b
--- /dev/null
+++ b/addons/point_of_sale/static/img/barcode.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/desk_organizer.png b/addons/point_of_sale/static/img/desk_organizer.png
new file mode 100644
index 00000000..880f69b9
--- /dev/null
+++ b/addons/point_of_sale/static/img/desk_organizer.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/desk_pad.png b/addons/point_of_sale/static/img/desk_pad.png
new file mode 100644
index 00000000..b335d9f6
--- /dev/null
+++ b/addons/point_of_sale/static/img/desk_pad.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/led_lamp.png b/addons/point_of_sale/static/img/led_lamp.png
new file mode 100644
index 00000000..9e7cf633
--- /dev/null
+++ b/addons/point_of_sale/static/img/led_lamp.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/letter_tray.png b/addons/point_of_sale/static/img/letter_tray.png
new file mode 100644
index 00000000..faaf88ac
--- /dev/null
+++ b/addons/point_of_sale/static/img/letter_tray.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/magnetic_board.png b/addons/point_of_sale/static/img/magnetic_board.png
new file mode 100644
index 00000000..edb7def2
--- /dev/null
+++ b/addons/point_of_sale/static/img/magnetic_board.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/monitor_stand.png b/addons/point_of_sale/static/img/monitor_stand.png
new file mode 100644
index 00000000..bea3d135
--- /dev/null
+++ b/addons/point_of_sale/static/img/monitor_stand.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/newspaper_stand.png b/addons/point_of_sale/static/img/newspaper_stand.png
new file mode 100644
index 00000000..ae87417e
--- /dev/null
+++ b/addons/point_of_sale/static/img/newspaper_stand.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/acsone.png b/addons/point_of_sale/static/img/partners/acsone.png
new file mode 100644
index 00000000..f6ec9893
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/acsone.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/bhc.jpg b/addons/point_of_sale/static/img/partners/bhc.jpg
new file mode 100644
index 00000000..30005451
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/bhc.jpg
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/camptocamp.png b/addons/point_of_sale/static/img/partners/camptocamp.png
new file mode 100644
index 00000000..0cb138e1
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/camptocamp.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/datalp.jpg b/addons/point_of_sale/static/img/partners/datalp.jpg
new file mode 100644
index 00000000..9ef1f913
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/datalp.jpg
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/eezee-it.png b/addons/point_of_sale/static/img/partners/eezee-it.png
new file mode 100644
index 00000000..2e975e5a
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/eezee-it.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/eggs-solutions.jpg b/addons/point_of_sale/static/img/partners/eggs-solutions.jpg
new file mode 100644
index 00000000..b15656b0
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/eggs-solutions.jpg
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/ekomurz.gif b/addons/point_of_sale/static/img/partners/ekomurz.gif
new file mode 100644
index 00000000..73905684
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/ekomurz.gif
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/openbig.jpg b/addons/point_of_sale/static/img/partners/openbig.jpg
new file mode 100644
index 00000000..88be89bf
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/openbig.jpg
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/services.jpg b/addons/point_of_sale/static/img/partners/services.jpg
new file mode 100644
index 00000000..915f21fc
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/services.jpg
Binary files differ
diff --git a/addons/point_of_sale/static/img/partners/vauxoo.png b/addons/point_of_sale/static/img/partners/vauxoo.png
new file mode 100644
index 00000000..be52bd1f
--- /dev/null
+++ b/addons/point_of_sale/static/img/partners/vauxoo.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/small_shelf.png b/addons/point_of_sale/static/img/small_shelf.png
new file mode 100644
index 00000000..5cccc2bb
--- /dev/null
+++ b/addons/point_of_sale/static/img/small_shelf.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/storage.png b/addons/point_of_sale/static/img/storage.png
new file mode 100644
index 00000000..484d02f1
--- /dev/null
+++ b/addons/point_of_sale/static/img/storage.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/wall_shelf_unit.png b/addons/point_of_sale/static/img/wall_shelf_unit.png
new file mode 100644
index 00000000..7fea22be
--- /dev/null
+++ b/addons/point_of_sale/static/img/wall_shelf_unit.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/whiteboard.png b/addons/point_of_sale/static/img/whiteboard.png
new file mode 100644
index 00000000..10720871
--- /dev/null
+++ b/addons/point_of_sale/static/img/whiteboard.png
Binary files differ
diff --git a/addons/point_of_sale/static/img/whiteboard_pen.png b/addons/point_of_sale/static/img/whiteboard_pen.png
new file mode 100644
index 00000000..db39c09f
--- /dev/null
+++ b/addons/point_of_sale/static/img/whiteboard_pen.png
Binary files differ
diff --git a/addons/point_of_sale/static/lib/backbone/backbone.js b/addons/point_of_sale/static/lib/backbone/backbone.js
new file mode 100644
index 00000000..f7783c2c
--- /dev/null
+++ b/addons/point_of_sale/static/lib/backbone/backbone.js
@@ -0,0 +1,1581 @@
+// Backbone.js 1.1.0
+
+// (c) 2010-2011 Jeremy Ashkenas, DocumentCloud Inc.
+// (c) 2011-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
+// Backbone may be freely distributed under the MIT license.
+// For all details and documentation:
+// http://backbonejs.org
+
+(function(){
+
+ // Initial Setup
+ // -------------
+
+ // Save a reference to the global object (`window` in the browser, `exports`
+ // on the server).
+ var root = this;
+
+ // Save the previous value of the `Backbone` variable, so that it can be
+ // restored later on, if `noConflict` is used.
+ var previousBackbone = root.Backbone;
+
+ // Create local references to array methods we'll want to use later.
+ var array = [];
+ var push = array.push;
+ var slice = array.slice;
+ var splice = array.splice;
+
+ // The top-level namespace. All public Backbone classes and modules will
+ // be attached to this. Exported for both the browser and the server.
+ var Backbone;
+ if (typeof exports !== 'undefined') {
+ Backbone = exports;
+ } else {
+ Backbone = root.Backbone = {};
+ }
+
+ // Current version of the library. Keep in sync with `package.json`.
+ Backbone.VERSION = '1.1.0';
+
+ // Require Underscore, if we're on the server, and it's not already present.
+ var _ = root._;
+ if (!_ && (typeof require !== 'undefined')) _ = require('underscore');
+
+ // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns
+ // the `$` variable.
+ Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$;
+
+ // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable
+ // to its previous owner. Returns a reference to this Backbone object.
+ Backbone.noConflict = function() {
+ root.Backbone = previousBackbone;
+ return this;
+ };
+
+ // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option
+ // will fake `"PATCH"`, `"PUT"` and `"DELETE"` requests via the `_method` parameter and
+ // set a `X-Http-Method-Override` header.
+ Backbone.emulateHTTP = false;
+
+ // Turn on `emulateJSON` to support legacy servers that can't deal with direct
+ // `application/json` requests ... will encode the body as
+ // `application/x-www-form-urlencoded` instead and will send the model in a
+ // form param named `model`.
+ Backbone.emulateJSON = false;
+
+ // Backbone.Events
+ // ---------------
+
+ // A module that can be mixed in to *any object* in order to provide it with
+ // custom events. You may bind with `on` or remove with `off` callback
+ // functions to an event; `trigger`-ing an event fires all callbacks in
+ // succession.
+ //
+ // var object = {};
+ // _.extend(object, Backbone.Events);
+ // object.on('expand', function(){ alert('expanded'); });
+ // object.trigger('expand');
+ //
+ var Events = Backbone.Events = {
+
+ // Bind an event to a `callback` function. Passing `"all"` will bind
+ // the callback to all events fired.
+ on: function(name, callback, context) {
+ if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this;
+ this._events || (this._events = {});
+ var events = this._events[name] || (this._events[name] = []);
+ events.push({callback: callback, context: context, ctx: context || this});
+ return this;
+ },
+
+ // Bind an event to only be triggered a single time. After the first time
+ // the callback is invoked, it will be removed.
+ once: function(name, callback, context) {
+ if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this;
+ var self = this;
+ var once = _.once(function() {
+ self.off(name, once);
+ callback.apply(this, arguments);
+ });
+ once._callback = callback;
+ return this.on(name, once, context);
+ },
+
+ // Remove one or many callbacks. If `context` is null, removes all
+ // callbacks with that function. If `callback` is null, removes all
+ // callbacks for the event. If `name` is null, removes all bound
+ // callbacks for all events.
+ off: function(name, callback, context) {
+ var retain, ev, events, names, i, l, j, k;
+ if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this;
+ if (!name && !callback && !context) {
+ this._events = {};
+ return this;
+ }
+ names = name ? [name] : _.keys(this._events);
+ for (i = 0, l = names.length; i < l; i++) {
+ name = names[i];
+ if (events = this._events[name]) {
+ this._events[name] = retain = [];
+ if (callback || context) {
+ for (j = 0, k = events.length; j < k; j++) {
+ ev = events[j];
+ if ((callback && callback !== ev.callback && callback !== ev.callback._callback) ||
+ (context && context !== ev.context)) {
+ retain.push(ev);
+ }
+ }
+ }
+ if (!retain.length) delete this._events[name];
+ }
+ }
+
+ return this;
+ },
+
+ // Trigger one or many events, firing all bound callbacks. Callbacks are
+ // passed the same arguments as `trigger` is, apart from the event name
+ // (unless you're listening on `"all"`, which will cause your callback to
+ // receive the true name of the event as the first argument).
+ trigger: function(name) {
+ if (!this._events) return this;
+ var args = slice.call(arguments, 1);
+ if (!eventsApi(this, 'trigger', name, args)) return this;
+ var events = this._events[name];
+ var allEvents = this._events.all;
+ if (events) triggerEvents(events, args);
+ if (allEvents) triggerEvents(allEvents, arguments);
+ return this;
+ },
+
+ // Tell this object to stop listening to either specific events ... or
+ // to every object it's currently listening to.
+ stopListening: function(obj, name, callback) {
+ var listeningTo = this._listeningTo;
+ if (!listeningTo) return this;
+ var remove = !name && !callback;
+ if (!callback && typeof name === 'object') callback = this;
+ if (obj) (listeningTo = {})[obj._listenId] = obj;
+ for (var id in listeningTo) {
+ obj = listeningTo[id];
+ obj.off(name, callback, this);
+ if (remove || _.isEmpty(obj._events)) delete this._listeningTo[id];
+ }
+ return this;
+ }
+
+ };
+
+ // Regular expression used to split event strings.
+ var eventSplitter = /\s+/;
+
+ // Implement fancy features of the Events API such as multiple event
+ // names `"change blur"` and jQuery-style event maps `{change: action}`
+ // in terms of the existing API.
+ var eventsApi = function(obj, action, name, rest) {
+ if (!name) return true;
+
+ // Handle event maps.
+ if (typeof name === 'object') {
+ for (var key in name) {
+ obj[action].apply(obj, [key, name[key]].concat(rest));
+ }
+ return false;
+ }
+
+ // Handle space separated event names.
+ if (eventSplitter.test(name)) {
+ var names = name.split(eventSplitter);
+ for (var i = 0, l = names.length; i < l; i++) {
+ obj[action].apply(obj, [names[i]].concat(rest));
+ }
+ return false;
+ }
+
+ return true;
+ };
+
+ // A difficult-to-believe, but optimized internal dispatch function for
+ // triggering events. Tries to keep the usual cases speedy (most internal
+ // Backbone events have 3 arguments).
+ var triggerEvents = function(events, args) {
+ var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2];
+ switch (args.length) {
+ case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return;
+ case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return;
+ case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return;
+ case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return;
+ default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args);
+ }
+ };
+
+ var listenMethods = {listenTo: 'on', listenToOnce: 'once'};
+
+ // Inversion-of-control versions of `on` and `once`. Tell *this* object to
+ // listen to an event in another object ... keeping track of what it's
+ // listening to.
+ _.each(listenMethods, function(implementation, method) {
+ Events[method] = function(obj, name, callback) {
+ var listeningTo = this._listeningTo || (this._listeningTo = {});
+ var id = obj._listenId || (obj._listenId = _.uniqueId('l'));
+ listeningTo[id] = obj;
+ if (!callback && typeof name === 'object') callback = this;
+ obj[implementation](name, callback, this);
+ return this;
+ };
+ });
+
+ // Aliases for backwards compatibility.
+ Events.bind = Events.on;
+ Events.unbind = Events.off;
+
+ // Allow the `Backbone` object to serve as a global event bus, for folks who
+ // want global "pubsub" in a convenient place.
+ _.extend(Backbone, Events);
+
+ // Backbone.Model
+ // --------------
+
+ // Backbone **Models** are the basic data object in the framework --
+ // frequently representing a row in a table in a database on your server.
+ // A discrete chunk of data and a bunch of useful, related methods for
+ // performing computations and transformations on that data.
+
+ // Create a new model with the specified attributes. A client id (`cid`)
+ // is automatically generated and assigned for you.
+ var Model = Backbone.Model = function(attributes, options) {
+ var attrs = attributes || {};
+ options || (options = {});
+ this.cid = _.uniqueId('c');
+ this.attributes = {};
+ if (options.collection) this.collection = options.collection;
+ if (options.parse) attrs = this.parse(attrs, options) || {};
+ attrs = _.defaults({}, attrs, _.result(this, 'defaults'));
+ this.set(attrs, options);
+ this.changed = {};
+ this.initialize.apply(this, arguments);
+ };
+
+ // Attach all inheritable methods to the Model prototype.
+ _.extend(Model.prototype, Events, {
+
+ // A hash of attributes whose current and previous value differ.
+ changed: null,
+
+ // The value returned during the last failed validation.
+ validationError: null,
+
+ // The default name for the JSON `id` attribute is `"id"`. MongoDB and
+ // CouchDB users may want to set this to `"_id"`.
+ idAttribute: 'id',
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // Return a copy of the model's `attributes` object.
+ toJSON: function(options) {
+ return _.clone(this.attributes);
+ },
+
+ // Proxy `Backbone.sync` by default -- but override this if you need
+ // custom syncing semantics for *this* particular model.
+ sync: function() {
+ return Backbone.sync.apply(this, arguments);
+ },
+
+ // Get the value of an attribute.
+ get: function(attr) {
+ return this.attributes[attr];
+ },
+
+ // Get the HTML-escaped value of an attribute.
+ escape: function(attr) {
+ return _.escape(this.get(attr));
+ },
+
+ // Returns `true` if the attribute contains a value that is not null
+ // or undefined.
+ has: function(attr) {
+ return this.get(attr) != null;
+ },
+
+ // Set a hash of model attributes on the object, firing `"change"`. This is
+ // the core primitive operation of a model, updating the data and notifying
+ // anyone who needs to know about the change in state. The heart of the beast.
+ set: function(key, val, options) {
+ var attr, attrs, unset, changes, silent, changing, prev, current;
+ if (key == null) return this;
+
+ // Handle both `"key", value` and `{key: value}` -style arguments.
+ if (typeof key === 'object') {
+ attrs = key;
+ options = val;
+ } else {
+ (attrs = {})[key] = val;
+ }
+
+ options || (options = {});
+
+ // Run validation.
+ if (!this._validate(attrs, options)) return false;
+
+ // Extract attributes and options.
+ unset = options.unset;
+ silent = options.silent;
+ changes = [];
+ changing = this._changing;
+ this._changing = true;
+
+ if (!changing) {
+ this._previousAttributes = _.clone(this.attributes);
+ this.changed = {};
+ }
+ current = this.attributes, prev = this._previousAttributes;
+
+ // Check for changes of `id`.
+ if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];
+
+ // For each `set` attribute, update or delete the current value.
+ for (attr in attrs) {
+ val = attrs[attr];
+ if (!_.isEqual(current[attr], val)) changes.push(attr);
+ if (!_.isEqual(prev[attr], val)) {
+ this.changed[attr] = val;
+ } else {
+ delete this.changed[attr];
+ }
+ unset ? delete current[attr] : current[attr] = val;
+ }
+
+ // Trigger all relevant attribute changes.
+ if (!silent) {
+ if (changes.length) this._pending = true;
+ for (var i = 0, l = changes.length; i < l; i++) {
+ this.trigger('change:' + changes[i], this, current[changes[i]], options);
+ }
+ }
+
+ // You might be wondering why there's a `while` loop here. Changes can
+ // be recursively nested within `"change"` events.
+ if (changing) return this;
+ if (!silent) {
+ while (this._pending) {
+ this._pending = false;
+ this.trigger('change', this, options);
+ }
+ }
+ this._pending = false;
+ this._changing = false;
+ return this;
+ },
+
+ // Remove an attribute from the model, firing `"change"`. `unset` is a noop
+ // if the attribute doesn't exist.
+ unset: function(attr, options) {
+ return this.set(attr, void 0, _.extend({}, options, {unset: true}));
+ },
+
+ // Clear all attributes on the model, firing `"change"`.
+ clear: function(options) {
+ var attrs = {};
+ for (var key in this.attributes) attrs[key] = void 0;
+ return this.set(attrs, _.extend({}, options, {unset: true}));
+ },
+
+ // Determine if the model has changed since the last `"change"` event.
+ // If you specify an attribute name, determine if that attribute has changed.
+ hasChanged: function(attr) {
+ if (attr == null) return !_.isEmpty(this.changed);
+ return _.has(this.changed, attr);
+ },
+
+ // Return an object containing all the attributes that have changed, or
+ // false if there are no changed attributes. Useful for determining what
+ // parts of a view need to be updated and/or what attributes need to be
+ // persisted to the server. Unset attributes will be set to undefined.
+ // You can also pass an attributes object to diff against the model,
+ // determining if there *would be* a change.
+ changedAttributes: function(diff) {
+ if (!diff) return this.hasChanged() ? _.clone(this.changed) : false;
+ var val, changed = false;
+ var old = this._changing ? this._previousAttributes : this.attributes;
+ for (var attr in diff) {
+ if (_.isEqual(old[attr], (val = diff[attr]))) continue;
+ (changed || (changed = {}))[attr] = val;
+ }
+ return changed;
+ },
+
+ // Get the previous value of an attribute, recorded at the time the last
+ // `"change"` event was fired.
+ previous: function(attr) {
+ if (attr == null || !this._previousAttributes) return null;
+ return this._previousAttributes[attr];
+ },
+
+ // Get all of the attributes of the model at the time of the previous
+ // `"change"` event.
+ previousAttributes: function() {
+ return _.clone(this._previousAttributes);
+ },
+
+ // Fetch the model from the server. If the server's representation of the
+ // model differs from its current attributes, they will be overridden,
+ // triggering a `"change"` event.
+ fetch: function(options) {
+ options = options ? _.clone(options) : {};
+ if (options.parse === void 0) options.parse = true;
+ var model = this;
+ var success = options.success;
+ options.success = function(resp) {
+ if (!model.set(model.parse(resp, options), options)) return false;
+ if (success) success(model, resp, options);
+ model.trigger('sync', model, resp, options);
+ };
+ wrapError(this, options);
+ return this.sync('read', this, options);
+ },
+
+ // Set a hash of model attributes, and sync the model to the server.
+ // If the server returns an attributes hash that differs, the model's
+ // state will be `set` again.
+ save: function(key, val, options) {
+ var attrs, method, xhr, attributes = this.attributes;
+
+ // Handle both `"key", value` and `{key: value}` -style arguments.
+ if (key == null || typeof key === 'object') {
+ attrs = key;
+ options = val;
+ } else {
+ (attrs = {})[key] = val;
+ }
+
+ options = _.extend({validate: true}, options);
+
+ // If we're not waiting and attributes exist, save acts as
+ // `set(attr).save(null, opts)` with validation. Otherwise, check if
+ // the model will be valid when the attributes, if any, are set.
+ if (attrs && !options.wait) {
+ if (!this.set(attrs, options)) return false;
+ } else {
+ if (!this._validate(attrs, options)) return false;
+ }
+
+ // Set temporary attributes if `{wait: true}`.
+ if (attrs && options.wait) {
+ this.attributes = _.extend({}, attributes, attrs);
+ }
+
+ // After a successful server-side save, the client is (optionally)
+ // updated with the server-side state.
+ if (options.parse === void 0) options.parse = true;
+ var model = this;
+ var success = options.success;
+ options.success = function(resp) {
+ // Ensure attributes are restored during synchronous saves.
+ model.attributes = attributes;
+ var serverAttrs = model.parse(resp, options);
+ if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs);
+ if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) {
+ return false;
+ }
+ if (success) success(model, resp, options);
+ model.trigger('sync', model, resp, options);
+ };
+ wrapError(this, options);
+
+ method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update');
+ if (method === 'patch') options.attrs = attrs;
+ xhr = this.sync(method, this, options);
+
+ // Restore attributes.
+ if (attrs && options.wait) this.attributes = attributes;
+
+ return xhr;
+ },
+
+ // Destroy this model on the server if it was already persisted.
+ // Optimistically removes the model from its collection, if it has one.
+ // If `wait: true` is passed, waits for the server to respond before removal.
+ destroy: function(options) {
+ options = options ? _.clone(options) : {};
+ var model = this;
+ var success = options.success;
+
+ var destroy = function() {
+ model.trigger('destroy', model, model.collection, options);
+ };
+
+ options.success = function(resp) {
+ if (options.wait || model.isNew()) destroy();
+ if (success) success(model, resp, options);
+ if (!model.isNew()) model.trigger('sync', model, resp, options);
+ };
+
+ if (this.isNew()) {
+ options.success();
+ return false;
+ }
+ wrapError(this, options);
+
+ var xhr = this.sync('delete', this, options);
+ if (!options.wait) destroy();
+ return xhr;
+ },
+
+ // Default URL for the model's representation on the server -- if you're
+ // using Backbone's restful methods, override this to change the endpoint
+ // that will be called.
+ url: function() {
+ var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError();
+ if (this.isNew()) return base;
+ return base + (base.charAt(base.length - 1) === '/' ? '' : '/') + encodeURIComponent(this.id);
+ },
+
+ // **parse** converts a response into the hash of attributes to be `set` on
+ // the model. The default implementation is just to pass the response along.
+ parse: function(resp, options) {
+ return resp;
+ },
+
+ // Create a new model with identical attributes to this one.
+ clone: function() {
+ return new this.constructor(this.attributes);
+ },
+
+ // A model is new if it has never been saved to the server, and lacks an id.
+ isNew: function() {
+ return this.id == null;
+ },
+
+ // Check if the model is currently in a valid state.
+ isValid: function(options) {
+ return this._validate({}, _.extend(options || {}, { validate: true }));
+ },
+
+ // Run validation against the next complete set of model attributes,
+ // returning `true` if all is well. Otherwise, fire an `"invalid"` event.
+ _validate: function(attrs, options) {
+ if (!options.validate || !this.validate) return true;
+ attrs = _.extend({}, this.attributes, attrs);
+ var error = this.validationError = this.validate(attrs, options) || null;
+ if (!error) return true;
+ this.trigger('invalid', this, error, _.extend(options, {validationError: error}));
+ return false;
+ }
+
+ });
+
+ // Underscore methods that we want to implement on the Model.
+ var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit'];
+
+ // Mix in each Underscore method as a proxy to `Model#attributes`.
+ _.each(modelMethods, function(method) {
+ Model.prototype[method] = function() {
+ var args = slice.call(arguments);
+ args.unshift(this.attributes);
+ return _[method].apply(_, args);
+ };
+ });
+
+ // Backbone.Collection
+ // -------------------
+
+ // If models tend to represent a single row of data, a Backbone Collection is
+ // more analagous to a table full of data ... or a small slice or page of that
+ // table, or a collection of rows that belong together for a particular reason
+ // -- all of the messages in this particular folder, all of the documents
+ // belonging to this particular author, and so on. Collections maintain
+ // indexes of their models, both in order, and for lookup by `id`.
+
+ // Create a new **Collection**, perhaps to contain a specific type of `model`.
+ // If a `comparator` is specified, the Collection will maintain
+ // its models in sort order, as they're added and removed.
+ var Collection = Backbone.Collection = function(models, options) {
+ options || (options = {});
+ if (options.model) this.model = options.model;
+ if (options.comparator !== void 0) this.comparator = options.comparator;
+ this._reset();
+ this.initialize.apply(this, arguments);
+ if (models) this.reset(models, _.extend({silent: true}, options));
+ };
+
+ // Default options for `Collection#set`.
+ var setOptions = {add: true, remove: true, merge: true};
+ var addOptions = {add: true, remove: false};
+
+ // Define the Collection's inheritable methods.
+ _.extend(Collection.prototype, Events, {
+
+ // The default model for a collection is just a **Backbone.Model**.
+ // This should be overridden in most cases.
+ model: Model,
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // The JSON representation of a Collection is an array of the
+ // models' attributes.
+ toJSON: function(options) {
+ return this.map(function(model){ return model.toJSON(options); });
+ },
+
+ // Proxy `Backbone.sync` by default.
+ sync: function() {
+ return Backbone.sync.apply(this, arguments);
+ },
+
+ // Add a model, or list of models to the set.
+ add: function(models, options) {
+ return this.set(models, _.extend({merge: false}, options, addOptions));
+ },
+
+ // Remove a model, or a list of models from the set.
+ remove: function(models, options) {
+ var singular = !_.isArray(models);
+ models = singular ? [models] : _.clone(models);
+ options || (options = {});
+ var i, l, index, model;
+ for (i = 0, l = models.length; i < l; i++) {
+ model = models[i] = this.get(models[i]);
+ if (!model) continue;
+ delete this._byId[model.id];
+ delete this._byId[model.cid];
+ index = this.indexOf(model);
+ this.models.splice(index, 1);
+ this.length--;
+ if (!options.silent) {
+ options.index = index;
+ model.trigger('remove', model, this, options);
+ }
+ this._removeReference(model);
+ }
+ return singular ? models[0] : models;
+ },
+
+ // Update a collection by `set`-ing a new list of models, adding new ones,
+ // removing models that are no longer present, and merging models that
+ // already exist in the collection, as necessary. Similar to **Model#set**,
+ // the core operation for updating the data contained by the collection.
+ set: function(models, options) {
+ options = _.defaults({}, options, setOptions);
+ if (options.parse) models = this.parse(models, options);
+ var singular = !_.isArray(models);
+ models = singular ? (models ? [models] : []) : _.clone(models);
+ var i, l, id, model, attrs, existing, sort;
+ var at = options.at;
+ var targetModel = this.model;
+ var sortable = this.comparator && (at == null) && options.sort !== false;
+ var sortAttr = _.isString(this.comparator) ? this.comparator : null;
+ var toAdd = [], toRemove = [], modelMap = {};
+ var add = options.add, merge = options.merge, remove = options.remove;
+ var order = !sortable && add && remove ? [] : false;
+
+ // Turn bare objects into model references, and prevent invalid models
+ // from being added.
+ for (i = 0, l = models.length; i < l; i++) {
+ attrs = models[i];
+ if (attrs instanceof Model) {
+ id = model = attrs;
+ } else {
+ id = attrs[targetModel.prototype.idAttribute];
+ }
+
+ // If a duplicate is found, prevent it from being added and
+ // optionally merge it into the existing model.
+ if (existing = this.get(id)) {
+ if (remove) modelMap[existing.cid] = true;
+ if (merge) {
+ attrs = attrs === model ? model.attributes : attrs;
+ if (options.parse) attrs = existing.parse(attrs, options);
+ existing.set(attrs, options);
+ if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true;
+ }
+ models[i] = existing;
+
+ // If this is a new, valid model, push it to the `toAdd` list.
+ } else if (add) {
+ model = models[i] = this._prepareModel(attrs, options);
+ if (!model) continue;
+ toAdd.push(model);
+
+ // Listen to added models' events, and index models for lookup by
+ // `id` and by `cid`.
+ model.on('all', this._onModelEvent, this);
+ this._byId[model.cid] = model;
+ if (model.id != null) this._byId[model.id] = model;
+ }
+ if (order) order.push(existing || model);
+ }
+
+ // Remove nonexistent models if appropriate.
+ if (remove) {
+ for (i = 0, l = this.length; i < l; ++i) {
+ if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model);
+ }
+ if (toRemove.length) this.remove(toRemove, options);
+ }
+
+ // See if sorting is needed, update `length` and splice in new models.
+ if (toAdd.length || (order && order.length)) {
+ if (sortable) sort = true;
+ this.length += toAdd.length;
+ if (at != null) {
+ for (i = 0, l = toAdd.length; i < l; i++) {
+ this.models.splice(at + i, 0, toAdd[i]);
+ }
+ } else {
+ if (order) this.models.length = 0;
+ var orderedModels = order || toAdd;
+ for (i = 0, l = orderedModels.length; i < l; i++) {
+ this.models.push(orderedModels[i]);
+ }
+ }
+ }
+
+ // Silently sort the collection if appropriate.
+ if (sort) this.sort({silent: true});
+
+ // Unless silenced, it's time to fire all appropriate add/sort events.
+ if (!options.silent) {
+ for (i = 0, l = toAdd.length; i < l; i++) {
+ (model = toAdd[i]).trigger('add', model, this, options);
+ }
+ if (sort || (order && order.length)) this.trigger('sort', this, options);
+ }
+
+ // Return the added (or merged) model (or models).
+ return singular ? models[0] : models;
+ },
+
+ // When you have more items than you want to add or remove individually,
+ // you can reset the entire set with a new list of models, without firing
+ // any granular `add` or `remove` events. Fires `reset` when finished.
+ // Useful for bulk operations and optimizations.
+ reset: function(models, options) {
+ options || (options = {});
+ for (var i = 0, l = this.models.length; i < l; i++) {
+ this._removeReference(this.models[i]);
+ }
+ options.previousModels = this.models;
+ this._reset();
+ models = this.add(models, _.extend({silent: true}, options));
+ if (!options.silent) this.trigger('reset', this, options);
+ return models;
+ },
+
+ // Add a model to the end of the collection.
+ push: function(model, options) {
+ return this.add(model, _.extend({at: this.length}, options));
+ },
+
+ // Remove a model from the end of the collection.
+ pop: function(options) {
+ var model = this.at(this.length - 1);
+ this.remove(model, options);
+ return model;
+ },
+
+ // Add a model to the beginning of the collection.
+ unshift: function(model, options) {
+ return this.add(model, _.extend({at: 0}, options));
+ },
+
+ // Remove a model from the beginning of the collection.
+ shift: function(options) {
+ var model = this.at(0);
+ this.remove(model, options);
+ return model;
+ },
+
+ // Slice out a sub-array of models from the collection.
+ slice: function() {
+ return slice.apply(this.models, arguments);
+ },
+
+ // Get a model from the set by id.
+ get: function(obj) {
+ if (obj == null) return void 0;
+ return this._byId[obj.id] || this._byId[obj.cid] || this._byId[obj];
+ },
+
+ // Get the model at the given index.
+ at: function(index) {
+ return this.models[index];
+ },
+
+ // Return models with matching attributes. Useful for simple cases of
+ // `filter`.
+ where: function(attrs, first) {
+ if (_.isEmpty(attrs)) return first ? void 0 : [];
+ return this[first ? 'find' : 'filter'](function(model) {
+ for (var key in attrs) {
+ if (attrs[key] !== model.get(key)) return false;
+ }
+ return true;
+ });
+ },
+
+ // Return the first model with matching attributes. Useful for simple cases
+ // of `find`.
+ findWhere: function(attrs) {
+ return this.where(attrs, true);
+ },
+
+ // Force the collection to re-sort itself. You don't need to call this under
+ // normal circumstances, as the set will maintain sort order as each item
+ // is added.
+ sort: function(options) {
+ if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
+ options || (options = {});
+
+ // Run sort based on type of `comparator`.
+ if (_.isString(this.comparator) || this.comparator.length === 1) {
+ this.models = this.sortBy(this.comparator, this);
+ } else {
+ this.models.sort(_.bind(this.comparator, this));
+ }
+
+ if (!options.silent) this.trigger('sort', this, options);
+ return this;
+ },
+
+ // Pluck an attribute from each model in the collection.
+ pluck: function(attr) {
+ return _.invoke(this.models, 'get', attr);
+ },
+
+ // Fetch the default set of models for this collection, resetting the
+ // collection when they arrive. If `reset: true` is passed, the response
+ // data will be passed through the `reset` method instead of `set`.
+ fetch: function(options) {
+ options = options ? _.clone(options) : {};
+ if (options.parse === void 0) options.parse = true;
+ var success = options.success;
+ var collection = this;
+ options.success = function(resp) {
+ var method = options.reset ? 'reset' : 'set';
+ collection[method](resp, options);
+ if (success) success(collection, resp, options);
+ collection.trigger('sync', collection, resp, options);
+ };
+ wrapError(this, options);
+ return this.sync('read', this, options);
+ },
+
+ // Create a new instance of a model in this collection. Add the model to the
+ // collection immediately, unless `wait: true` is passed, in which case we
+ // wait for the server to agree.
+ create: function(model, options) {
+ options = options ? _.clone(options) : {};
+ if (!(model = this._prepareModel(model, options))) return false;
+ if (!options.wait) this.add(model, options);
+ var collection = this;
+ var success = options.success;
+ options.success = function(model, resp, options) {
+ if (options.wait) collection.add(model, options);
+ if (success) success(model, resp, options);
+ };
+ model.save(null, options);
+ return model;
+ },
+
+ // **parse** converts a response into a list of models to be added to the
+ // collection. The default implementation is just to pass it through.
+ parse: function(resp, options) {
+ return resp;
+ },
+
+ // Create a new collection with an identical list of models as this one.
+ clone: function() {
+ return new this.constructor(this.models);
+ },
+
+ // Private method to reset all internal state. Called when the collection
+ // is first initialized or reset.
+ _reset: function() {
+ this.length = 0;
+ this.models = [];
+ this._byId = {};
+ },
+
+ // Prepare a hash of attributes (or other model) to be added to this
+ // collection.
+ _prepareModel: function(attrs, options) {
+ if (attrs instanceof Model) {
+ if (!attrs.collection) attrs.collection = this;
+ return attrs;
+ }
+ options = options ? _.clone(options) : {};
+ options.collection = this;
+ var model = new this.model(attrs, options);
+ if (!model.validationError) return model;
+ this.trigger('invalid', this, model.validationError, options);
+ return false;
+ },
+
+ // Internal method to sever a model's ties to a collection.
+ _removeReference: function(model) {
+ if (this === model.collection) delete model.collection;
+ model.off('all', this._onModelEvent, this);
+ },
+
+ // Internal method called every time a model in the set fires an event.
+ // Sets need to update their indexes when models change ids. All other
+ // events simply proxy through. "add" and "remove" events that originate
+ // in other collections are ignored.
+ _onModelEvent: function(event, model, collection, options) {
+ if ((event === 'add' || event === 'remove') && collection !== this) return;
+ if (event === 'destroy') this.remove(model, options);
+ if (model && event === 'change:' + model.idAttribute) {
+ delete this._byId[model.previous(model.idAttribute)];
+ if (model.id != null) this._byId[model.id] = model;
+ }
+ this.trigger.apply(this, arguments);
+ }
+
+ });
+
+ // Underscore methods that we want to implement on the Collection.
+ // 90% of the core usefulness of Backbone Collections is actually implemented
+ // right here:
+ var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl',
+ 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select',
+ 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke',
+ 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest',
+ 'tail', 'drop', 'last', 'without', 'difference', 'indexOf', 'shuffle',
+ 'lastIndexOf', 'isEmpty', 'chain'];
+
+ // Mix in each Underscore method as a proxy to `Collection#models`.
+ _.each(methods, function(method) {
+ Collection.prototype[method] = function() {
+ var args = slice.call(arguments);
+ args.unshift(this.models);
+ return _[method].apply(_, args);
+ };
+ });
+
+ // Underscore methods that take a property name as an argument.
+ var attributeMethods = ['groupBy', 'countBy', 'sortBy'];
+
+ // Use attributes instead of properties.
+ _.each(attributeMethods, function(method) {
+ Collection.prototype[method] = function(value, context) {
+ var iterator = _.isFunction(value) ? value : function(model) {
+ return model.get(value);
+ };
+ return _[method](this.models, iterator, context);
+ };
+ });
+
+ // Backbone.View
+ // -------------
+
+ // Backbone Views are almost more convention than they are actual code. A View
+ // is simply a JavaScript object that represents a logical chunk of UI in the
+ // DOM. This might be a single item, an entire list, a sidebar or panel, or
+ // even the surrounding frame which wraps your whole app. Defining a chunk of
+ // UI as a **View** allows you to define your DOM events declaratively, without
+ // having to worry about render order ... and makes it easy for the view to
+ // react to specific changes in the state of your models.
+
+ // Creating a Backbone.View creates its initial element outside of the DOM,
+ // if an existing element is not provided...
+ var View = Backbone.View = function(options) {
+ this.cid = _.uniqueId('view');
+ options || (options = {});
+ _.extend(this, _.pick(options, viewOptions));
+ this._ensureElement();
+ this.initialize.apply(this, arguments);
+ this.delegateEvents();
+ };
+
+ // Cached regex to split keys for `delegate`.
+ var delegateEventSplitter = /^(\S+)\s*(.*)$/;
+
+ // List of view options to be merged as properties.
+ var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events'];
+
+ // Set up all inheritable **Backbone.View** properties and methods.
+ _.extend(View.prototype, Events, {
+
+ // The default `tagName` of a View's element is `"div"`.
+ tagName: 'div',
+
+ // jQuery delegate for element lookup, scoped to DOM elements within the
+ // current view. This should be preferred to global lookups where possible.
+ $: function(selector) {
+ return this.$el.find(selector);
+ },
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // **render** is the core function that your view should override, in order
+ // to populate its element (`this.el`), with the appropriate HTML. The
+ // convention is for **render** to always return `this`.
+ render: function() {
+ return this;
+ },
+
+ // Remove this view by taking the element out of the DOM, and removing any
+ // applicable Backbone.Events listeners.
+ remove: function() {
+ this.$el.remove();
+ this.stopListening();
+ return this;
+ },
+
+ // Change the view's element (`this.el` property), including event
+ // re-delegation.
+ setElement: function(element, delegate) {
+ if (this.$el) this.undelegateEvents();
+ this.$el = element instanceof Backbone.$ ? element : Backbone.$(element);
+ this.el = this.$el[0];
+ if (delegate !== false) this.delegateEvents();
+ return this;
+ },
+
+ // Set callbacks, where `this.events` is a hash of
+ //
+ // *{"event selector": "callback"}*
+ //
+ // {
+ // 'mousedown .title': 'edit',
+ // 'click .button': 'save',
+ // 'click .open': function(e) { ... }
+ // }
+ //
+ // pairs. Callbacks will be bound to the view, with `this` set properly.
+ // Uses event delegation for efficiency.
+ // Omitting the selector binds the event to `this.el`.
+ // This only works for delegate-able events: not `focus`, `blur`, and
+ // not `change`, `submit`, and `reset` in Internet Explorer.
+ delegateEvents: function(events) {
+ if (!(events || (events = _.result(this, 'events')))) return this;
+ this.undelegateEvents();
+ for (var key in events) {
+ var method = events[key];
+ if (!_.isFunction(method)) method = this[events[key]];
+ if (!method) continue;
+
+ var match = key.match(delegateEventSplitter);
+ var eventName = match[1], selector = match[2];
+ method = _.bind(method, this);
+ eventName += '.delegateEvents' + this.cid;
+ if (selector === '') {
+ this.$el.on(eventName, method);
+ } else {
+ this.$el.on(eventName, selector, method);
+ }
+ }
+ return this;
+ },
+
+ // Clears all callbacks previously bound to the view with `delegateEvents`.
+ // You usually don't need to use this, but may wish to if you have multiple
+ // Backbone views attached to the same DOM element.
+ undelegateEvents: function() {
+ this.$el.off('.delegateEvents' + this.cid);
+ return this;
+ },
+
+ // Ensure that the View has a DOM element to render into.
+ // If `this.el` is a string, pass it through `$()`, take the first
+ // matching element, and re-assign it to `el`. Otherwise, create
+ // an element from the `id`, `className` and `tagName` properties.
+ _ensureElement: function() {
+ if (!this.el) {
+ var attrs = _.extend({}, _.result(this, 'attributes'));
+ if (this.id) attrs.id = _.result(this, 'id');
+ if (this.className) attrs['class'] = _.result(this, 'className');
+ var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs);
+ this.setElement($el, false);
+ } else {
+ this.setElement(_.result(this, 'el'), false);
+ }
+ }
+
+ });
+
+ // Backbone.sync
+ // -------------
+
+ // Override this function to change the manner in which Backbone persists
+ // models to the server. You will be passed the type of request, and the
+ // model in question. By default, makes a RESTful Ajax request
+ // to the model's `url()`. Some possible customizations could be:
+ //
+ // * Use `setTimeout` to batch rapid-fire updates into a single request.
+ // * Send up the models as XML instead of JSON.
+ // * Persist models via WebSockets instead of Ajax.
+ //
+ // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
+ // as `POST`, with a `_method` parameter containing the true HTTP method,
+ // as well as all requests with the body as `application/x-www-form-urlencoded`
+ // instead of `application/json` with the model in a param named `model`.
+ // Useful when interfacing with server-side languages like **PHP** that make
+ // it difficult to read the body of `PUT` requests.
+ Backbone.sync = function(method, model, options) {
+ var type = methodMap[method];
+
+ // Default options, unless specified.
+ _.defaults(options || (options = {}), {
+ emulateHTTP: Backbone.emulateHTTP,
+ emulateJSON: Backbone.emulateJSON
+ });
+
+ // Default JSON-request options.
+ var params = {type: type, dataType: 'json'};
+
+ // Ensure that we have a URL.
+ if (!options.url) {
+ params.url = _.result(model, 'url') || urlError();
+ }
+
+ // Ensure that we have the appropriate request data.
+ if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) {
+ params.contentType = 'application/json';
+ params.data = JSON.stringify(options.attrs || model.toJSON(options));
+ }
+
+ // For older servers, emulate JSON by encoding the request into an HTML-form.
+ if (options.emulateJSON) {
+ params.contentType = 'application/x-www-form-urlencoded';
+ params.data = params.data ? {model: params.data} : {};
+ }
+
+ // For older servers, emulate HTTP by mimicking the HTTP method with `_method`
+ // And an `X-HTTP-Method-Override` header.
+ if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) {
+ params.type = 'POST';
+ if (options.emulateJSON) params.data._method = type;
+ var beforeSend = options.beforeSend;
+ options.beforeSend = function(xhr) {
+ xhr.setRequestHeader('X-HTTP-Method-Override', type);
+ if (beforeSend) return beforeSend.apply(this, arguments);
+ };
+ }
+
+ // Don't process data on a non-GET request.
+ if (params.type !== 'GET' && !options.emulateJSON) {
+ params.processData = false;
+ }
+
+ // If we're sending a `PATCH` request, and we're in an old Internet Explorer
+ // that still has ActiveX enabled by default, override jQuery to use that
+ // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8.
+ if (params.type === 'PATCH' && noXhrPatch) {
+ params.xhr = function() {
+ return new ActiveXObject("Microsoft.XMLHTTP");
+ };
+ }
+
+ // Make the request, allowing the user to override any Ajax options.
+ var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
+ model.trigger('request', model, xhr, options);
+ return xhr;
+ };
+
+ var noXhrPatch = typeof window !== 'undefined' && !!window.ActiveXObject && !(window.XMLHttpRequest && (new XMLHttpRequest).dispatchEvent);
+
+ // Map from CRUD to HTTP for our default `Backbone.sync` implementation.
+ var methodMap = {
+ 'create': 'POST',
+ 'update': 'PUT',
+ 'patch': 'PATCH',
+ 'delete': 'DELETE',
+ 'read': 'GET'
+ };
+
+ // Set the default implementation of `Backbone.ajax` to proxy through to `$`.
+ // Override this if you'd like to use a different library.
+ Backbone.ajax = function() {
+ return Backbone.$.ajax.apply(Backbone.$, arguments);
+ };
+
+ // Backbone.Router
+ // ---------------
+
+ // Routers map faux-URLs to actions, and fire events when routes are
+ // matched. Creating a new one sets its `routes` hash, if not set statically.
+ var Router = Backbone.Router = function(options) {
+ options || (options = {});
+ if (options.routes) this.routes = options.routes;
+ this._bindRoutes();
+ this.initialize.apply(this, arguments);
+ };
+
+ // Cached regular expressions for matching named param parts and splatted
+ // parts of route strings.
+ var optionalParam = /\((.*?)\)/g;
+ var namedParam = /(\(\?)?:\w+/g;
+ var splatParam = /\*\w+/g;
+ var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g;
+
+ // Set up all inheritable **Backbone.Router** properties and methods.
+ _.extend(Router.prototype, Events, {
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize: function(){},
+
+ // Manually bind a single named route to a callback. For example:
+ //
+ // this.route('search/:query/p:num', 'search', function(query, num) {
+ // ...
+ // });
+ //
+ route: function(route, name, callback) {
+ if (!_.isRegExp(route)) route = this._routeToRegExp(route);
+ if (_.isFunction(name)) {
+ callback = name;
+ name = '';
+ }
+ if (!callback) callback = this[name];
+ var router = this;
+ Backbone.history.route(route, function(fragment) {
+ var args = router._extractParameters(route, fragment);
+ callback && callback.apply(router, args);
+ router.trigger.apply(router, ['route:' + name].concat(args));
+ router.trigger('route', name, args);
+ Backbone.history.trigger('route', router, name, args);
+ });
+ return this;
+ },
+
+ // Simple proxy to `Backbone.history` to save a fragment into the history.
+ navigate: function(fragment, options) {
+ Backbone.history.navigate(fragment, options);
+ return this;
+ },
+
+ // Bind all defined routes to `Backbone.history`. We have to reverse the
+ // order of the routes here to support behavior where the most general
+ // routes can be defined at the bottom of the route map.
+ _bindRoutes: function() {
+ if (!this.routes) return;
+ this.routes = _.result(this, 'routes');
+ var route, routes = _.keys(this.routes);
+ while ((route = routes.pop()) != null) {
+ this.route(route, this.routes[route]);
+ }
+ },
+
+ // Convert a route string into a regular expression, suitable for matching
+ // against the current location hash.
+ _routeToRegExp: function(route) {
+ route = route.replace(escapeRegExp, '\\$&')
+ .replace(optionalParam, '(?:$1)?')
+ .replace(namedParam, function(match, optional) {
+ return optional ? match : '([^\/]+)';
+ })
+ .replace(splatParam, '(.*?)');
+ return new RegExp('^' + route + '$');
+ },
+
+ // Given a route, and a URL fragment that it matches, return the array of
+ // extracted decoded parameters. Empty or unmatched parameters will be
+ // treated as `null` to normalize cross-browser behavior.
+ _extractParameters: function(route, fragment) {
+ var params = route.exec(fragment).slice(1);
+ return _.map(params, function(param) {
+ return param ? decodeURIComponent(param) : null;
+ });
+ }
+
+ });
+
+ // Backbone.History
+ // ----------------
+
+ // Handles cross-browser history management, based on either
+ // [pushState](http://diveintohtml5.info/history.html) and real URLs, or
+ // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange)
+ // and URL fragments. If the browser supports neither (old IE, natch),
+ // falls back to polling.
+ var History = Backbone.History = function() {
+ this.handlers = [];
+ _.bindAll(this, 'checkUrl');
+
+ // Ensure that `History` can be used outside of the browser.
+ if (typeof window !== 'undefined') {
+ this.location = window.location;
+ this.history = window.history;
+ }
+ };
+
+ // Cached regex for stripping a leading hash/slash and trailing space.
+ var routeStripper = /^[#\/]|\s+$/g;
+
+ // Cached regex for stripping leading and trailing slashes.
+ var rootStripper = /^\/+|\/+$/g;
+
+ // Cached regex for detecting MSIE.
+ var isExplorer = /msie [\w.]+/;
+
+ // Cached regex for removing a trailing slash.
+ var trailingSlash = /\/$/;
+
+ // Cached regex for stripping urls of hash and query.
+ var pathStripper = /[?#].*$/;
+
+ // Has the history handling already been started?
+ History.started = false;
+
+ // Set up all inheritable **Backbone.History** properties and methods.
+ _.extend(History.prototype, Events, {
+
+ // The default interval to poll for hash changes, if necessary, is
+ // twenty times a second.
+ interval: 50,
+
+ // Gets the true hash value. Cannot use location.hash directly due to bug
+ // in Firefox where location.hash will always be decoded.
+ getHash: function(window) {
+ var match = (window || this).location.href.match(/#(.*)$/);
+ return match ? match[1] : '';
+ },
+
+ // Get the cross-browser normalized URL fragment, either from the URL,
+ // the hash, or the override.
+ getFragment: function(fragment, forcePushState) {
+ if (fragment == null) {
+ if (this._hasPushState || !this._wantsHashChange || forcePushState) {
+ fragment = this.location.pathname;
+ var root = this.root.replace(trailingSlash, '');
+ if (!fragment.indexOf(root)) fragment = fragment.slice(root.length);
+ } else {
+ fragment = this.getHash();
+ }
+ }
+ return fragment.replace(routeStripper, '');
+ },
+
+ // Start the hash change handling, returning `true` if the current URL matches
+ // an existing route, and `false` otherwise.
+ start: function(options) {
+ if (History.started) throw new Error("Backbone.history has already been started");
+ History.started = true;
+
+ // Figure out the initial configuration. Do we need an iframe?
+ // Is pushState desired ... is it available?
+ this.options = _.extend({root: '/'}, this.options, options);
+ this.root = this.options.root;
+ this._wantsHashChange = this.options.hashChange !== false;
+ this._wantsPushState = !!this.options.pushState;
+ this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState);
+ var fragment = this.getFragment();
+ var docMode = document.documentMode;
+ var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7));
+
+ // Normalize root to always include a leading and trailing slash.
+ this.root = ('/' + this.root + '/').replace(rootStripper, '/');
+
+ if (oldIE && this._wantsHashChange) {
+ this.iframe = Backbone.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow;
+ this.navigate(fragment);
+ }
+
+ // Depending on whether we're using pushState or hashes, and whether
+ // 'onhashchange' is supported, determine how we check the URL state.
+ if (this._hasPushState) {
+ Backbone.$(window).on('popstate', this.checkUrl);
+ } else if (this._wantsHashChange && ('onhashchange' in window) && !oldIE) {
+ Backbone.$(window).on('hashchange', this.checkUrl);
+ } else if (this._wantsHashChange) {
+ this._checkUrlInterval = setInterval(this.checkUrl, this.interval);
+ }
+
+ // Determine if we need to change the base url, for a pushState link
+ // opened by a non-pushState browser.
+ this.fragment = fragment;
+ var loc = this.location;
+ var atRoot = loc.pathname.replace(/[^\/]$/, '$&/') === this.root;
+
+ // Transition from hashChange to pushState or vice versa if both are
+ // requested.
+ if (this._wantsHashChange && this._wantsPushState) {
+
+ // If we've started off with a route from a `pushState`-enabled
+ // browser, but we're currently in a browser that doesn't support it...
+ if (!this._hasPushState && !atRoot) {
+ this.fragment = this.getFragment(null, true);
+ this.location.replace(this.root + this.location.search + '#' + this.fragment);
+ // Return immediately as browser will do redirect to new url
+ return true;
+
+ // Or if we've started out with a hash-based route, but we're currently
+ // in a browser where it could be `pushState`-based instead...
+ } else if (this._hasPushState && atRoot && loc.hash) {
+ this.fragment = this.getHash().replace(routeStripper, '');
+ this.history.replaceState({}, document.title, this.root + this.fragment + loc.search);
+ }
+
+ }
+
+ if (!this.options.silent) return this.loadUrl();
+ },
+
+ // Disable Backbone.history, perhaps temporarily. Not useful in a real app,
+ // but possibly useful for unit testing Routers.
+ stop: function() {
+ Backbone.$(window).off('popstate', this.checkUrl).off('hashchange', this.checkUrl);
+ clearInterval(this._checkUrlInterval);
+ History.started = false;
+ },
+
+ // Add a route to be tested when the fragment changes. Routes added later
+ // may override previous routes.
+ route: function(route, callback) {
+ this.handlers.unshift({route: route, callback: callback});
+ },
+
+ // Checks the current URL to see if it has changed, and if it has,
+ // calls `loadUrl`, normalizing across the hidden iframe.
+ checkUrl: function(e) {
+ var current = this.getFragment();
+ if (current === this.fragment && this.iframe) {
+ current = this.getFragment(this.getHash(this.iframe));
+ }
+ if (current === this.fragment) return false;
+ if (this.iframe) this.navigate(current);
+ this.loadUrl();
+ },
+
+ // Attempt to load the current URL fragment. If a route succeeds with a
+ // match, returns `true`. If no defined routes matches the fragment,
+ // returns `false`.
+ loadUrl: function(fragment) {
+ fragment = this.fragment = this.getFragment(fragment);
+ return _.any(this.handlers, function(handler) {
+ if (handler.route.test(fragment)) {
+ handler.callback(fragment);
+ return true;
+ }
+ });
+ },
+
+ // Save a fragment into the hash history, or replace the URL state if the
+ // 'replace' option is passed. You are responsible for properly URL-encoding
+ // the fragment in advance.
+ //
+ // The options object can contain `trigger: true` if you wish to have the
+ // route callback be fired (not usually desirable), or `replace: true`, if
+ // you wish to modify the current URL without adding an entry to the history.
+ navigate: function(fragment, options) {
+ if (!History.started) return false;
+ if (!options || options === true) options = {trigger: !!options};
+
+ var url = this.root + (fragment = this.getFragment(fragment || ''));
+
+ // Strip the fragment of the query and hash for matching.
+ fragment = fragment.replace(pathStripper, '');
+
+ if (this.fragment === fragment) return;
+ this.fragment = fragment;
+
+ // Don't include a trailing slash on the root.
+ if (fragment === '' && url !== '/') url = url.slice(0, -1);
+
+ // If pushState is available, we use it to set the fragment as a real URL.
+ if (this._hasPushState) {
+ this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url);
+
+ // If hash changes haven't been explicitly disabled, update the hash
+ // fragment to store history.
+ } else if (this._wantsHashChange) {
+ this._updateHash(this.location, fragment, options.replace);
+ if (this.iframe && (fragment !== this.getFragment(this.getHash(this.iframe)))) {
+ // Opening and closing the iframe tricks IE7 and earlier to push a
+ // history entry on hash-tag change. When replace is true, we don't
+ // want this.
+ if(!options.replace) this.iframe.document.open().close();
+ this._updateHash(this.iframe.location, fragment, options.replace);
+ }
+
+ // If you've told us that you explicitly don't want fallback hashchange-
+ // based history, then `navigate` becomes a page refresh.
+ } else {
+ return this.location.assign(url);
+ }
+ if (options.trigger) return this.loadUrl(fragment);
+ },
+
+ // Update the hash location, either replacing the current entry, or adding
+ // a new one to the browser history.
+ _updateHash: function(location, fragment, replace) {
+ if (replace) {
+ var href = location.href.replace(/(javascript:|#).*$/, '');
+ location.replace(href + '#' + fragment);
+ } else {
+ // Some browsers require that `hash` contains a leading #.
+ location.hash = '#' + fragment;
+ }
+ }
+
+ });
+
+ // Create the default Backbone.history.
+ Backbone.history = new History;
+
+ // Helpers
+ // -------
+
+ // Helper function to correctly set up the prototype chain, for subclasses.
+ // Similar to `goog.inherits`, but uses a hash of prototype properties and
+ // class properties to be extended.
+ var extend = function(protoProps, staticProps) {
+ var parent = this;
+ var child;
+
+ // The constructor function for the new subclass is either defined by you
+ // (the "constructor" property in your `extend` definition), or defaulted
+ // by us to simply call the parent's constructor.
+ if (protoProps && _.has(protoProps, 'constructor')) {
+ child = protoProps.constructor;
+ } else {
+ child = function(){ return parent.apply(this, arguments); };
+ }
+
+ // Add static properties to the constructor function, if supplied.
+ _.extend(child, parent, staticProps);
+
+ // Set the prototype chain to inherit from `parent`, without calling
+ // `parent`'s constructor function.
+ var Surrogate = function(){ this.constructor = child; };
+ Surrogate.prototype = parent.prototype;
+ child.prototype = new Surrogate;
+
+ // Add prototype properties (instance properties) to the subclass,
+ // if supplied.
+ if (protoProps) _.extend(child.prototype, protoProps);
+
+ // Set a convenience property in case the parent's prototype is needed
+ // later.
+ child.__super__ = parent.prototype;
+
+ return child;
+ };
+
+ // Set up inheritance for the model, collection, router, view and history.
+ Model.extend = Collection.extend = Router.extend = View.extend = History.extend = extend;
+
+ // Throw an error when a URL is needed, and none is supplied.
+ var urlError = function() {
+ throw new Error('A "url" property or function must be specified');
+ };
+
+ // Wrap an optional error callback with a fallback error event.
+ var wrapError = function(model, options) {
+ var error = options.error;
+ options.error = function(resp) {
+ if (error) error(model, resp, options);
+ model.trigger('error', model, resp, options);
+ };
+ };
+
+}).call(this);
diff --git a/addons/point_of_sale/static/lib/html2canvas.js b/addons/point_of_sale/static/lib/html2canvas.js
new file mode 100644
index 00000000..4c228889
--- /dev/null
+++ b/addons/point_of_sale/static/lib/html2canvas.js
@@ -0,0 +1,2868 @@
+/*
+ html2canvas 0.4.1 <http://html2canvas.hertzen.com>
+ Copyright (c) 2013 Niklas von Hertzen
+
+ Released under MIT License
+*/
+
+(function(window, document, undefined){
+
+ "use strict";
+
+ var _html2canvas = {},
+ previousElement,
+ computedCSS,
+ html2canvas;
+
+ _html2canvas.Util = {};
+
+ _html2canvas.Util.log = function(a) {
+ if (_html2canvas.logging && window.console && window.console.log) {
+ window.console.log(a);
+ }
+ };
+
+ _html2canvas.Util.trimText = (function(isNative){
+ return function(input) {
+ return isNative ? isNative.apply(input) : ((input || '') + '').replace( /^\s+|\s+$/g , '' );
+ };
+ })(String.prototype.trim);
+
+ _html2canvas.Util.asFloat = function(v) {
+ return parseFloat(v);
+ };
+
+ (function() {
+ // TODO: support all possible length values
+ var TEXT_SHADOW_PROPERTY = /((rgba|rgb)\([^\)]+\)(\s-?\d+px){0,})/g;
+ var TEXT_SHADOW_VALUES = /(-?\d+px)|(#.+)|(rgb\(.+\))|(rgba\(.+\))/g;
+ _html2canvas.Util.parseTextShadows = function (value) {
+ if (!value || value === 'none') {
+ return [];
+ }
+
+ // find multiple shadow declarations
+ var shadows = value.match(TEXT_SHADOW_PROPERTY),
+ results = [];
+ for (var i = 0; shadows && (i < shadows.length); i++) {
+ var s = shadows[i].match(TEXT_SHADOW_VALUES);
+ results.push({
+ color: s[0],
+ offsetX: s[1] ? s[1].replace('px', '') : 0,
+ offsetY: s[2] ? s[2].replace('px', '') : 0,
+ blur: s[3] ? s[3].replace('px', '') : 0
+ });
+ }
+ return results;
+ };
+ })();
+
+
+ _html2canvas.Util.parseBackgroundImage = function (value) {
+ var whitespace = ' \r\n\t',
+ method, definition, prefix, prefix_i, block, results = [],
+ c, mode = 0, numParen = 0, quote, args;
+
+ var appendResult = function(){
+ if(method) {
+ if(definition.substr( 0, 1 ) === '"') {
+ definition = definition.substr( 1, definition.length - 2 );
+ }
+ if(definition) {
+ args.push(definition);
+ }
+ if(method.substr( 0, 1 ) === '-' &&
+ (prefix_i = method.indexOf( '-', 1 ) + 1) > 0) {
+ prefix = method.substr( 0, prefix_i);
+ method = method.substr( prefix_i );
+ }
+ results.push({
+ prefix: prefix,
+ method: method.toLowerCase(),
+ value: block,
+ args: args
+ });
+ }
+ args = []; //for some odd reason, setting .length = 0 didn't work in safari
+ method =
+ prefix =
+ definition =
+ block = '';
+ };
+
+ appendResult();
+ for(var i = 0, ii = value.length; i<ii; i++) {
+ c = value[i];
+ if(mode === 0 && whitespace.indexOf( c ) > -1){
+ continue;
+ }
+ switch(c) {
+ case '"':
+ if(!quote) {
+ quote = c;
+ }
+ else if(quote === c) {
+ quote = null;
+ }
+ break;
+
+ case '(':
+ if(quote) { break; }
+ else if(mode === 0) {
+ mode = 1;
+ block += c;
+ continue;
+ } else {
+ numParen++;
+ }
+ break;
+
+ case ')':
+ if(quote) { break; }
+ else if(mode === 1) {
+ if(numParen === 0) {
+ mode = 0;
+ block += c;
+ appendResult();
+ continue;
+ } else {
+ numParen--;
+ }
+ }
+ break;
+
+ case ',':
+ if(quote) { break; }
+ else if(mode === 0) {
+ appendResult();
+ continue;
+ }
+ else if (mode === 1) {
+ if(numParen === 0 && !method.match(/^url$/i)) {
+ args.push(definition);
+ definition = '';
+ block += c;
+ continue;
+ }
+ }
+ break;
+ }
+
+ block += c;
+ if(mode === 0) { method += c; }
+ else { definition += c; }
+ }
+ appendResult();
+
+ return results;
+ };
+
+ _html2canvas.Util.Bounds = function (element) {
+ var clientRect, bounds = {};
+
+ if (element.getBoundingClientRect){
+ clientRect = element.getBoundingClientRect();
+
+ // TODO add scroll position to bounds, so no scrolling of window necessary
+ bounds.top = clientRect.top;
+ bounds.bottom = clientRect.bottom || (clientRect.top + clientRect.height);
+ bounds.left = clientRect.left;
+
+ bounds.width = element.offsetWidth;
+ bounds.height = element.offsetHeight;
+ }
+
+ return bounds;
+ };
+
+ // TODO ideally, we'd want everything to go through this function instead of Util.Bounds,
+ // but would require further work to calculate the correct positions for elements with offsetParents
+ _html2canvas.Util.OffsetBounds = function (element) {
+ var parent = element.offsetParent ? _html2canvas.Util.OffsetBounds(element.offsetParent) : {top: 0, left: 0};
+
+ return {
+ top: element.offsetTop + parent.top,
+ bottom: element.offsetTop + element.offsetHeight + parent.top,
+ left: element.offsetLeft + parent.left,
+ width: element.offsetWidth,
+ height: element.offsetHeight
+ };
+ };
+
+ function toPX(element, attribute, value ) {
+ var rsLeft = element.runtimeStyle && element.runtimeStyle[attribute],
+ left,
+ style = element.style;
+
+ // Check if we are not dealing with pixels, (Opera has issues with this)
+ // Ported from jQuery css.js
+ // From the awesome hack by Dean Edwards
+ // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
+
+ // If we're not dealing with a regular pixel number
+ // but a number that has a weird ending, we need to convert it to pixels
+
+ if ( !/^-?[0-9]+\.?[0-9]*(?:px)?$/i.test( value ) && /^-?\d/.test(value) ) {
+ // Remember the original values
+ left = style.left;
+
+ // Put in the new values to get a computed value out
+ if (rsLeft) {
+ element.runtimeStyle.left = element.currentStyle.left;
+ }
+ style.left = attribute === "fontSize" ? "1em" : (value || 0);
+ value = style.pixelLeft + "px";
+
+ // Revert the changed values
+ style.left = left;
+ if (rsLeft) {
+ element.runtimeStyle.left = rsLeft;
+ }
+ }
+
+ if (!/^(thin|medium|thick)$/i.test(value)) {
+ return Math.round(parseFloat(value)) + "px";
+ }
+
+ return value;
+ }
+
+ function asInt(val) {
+ return parseInt(val, 10);
+ }
+
+ function parseBackgroundSizePosition(value, element, attribute, index) {
+ value = (value || '').split(',');
+ value = value[index || 0] || value[0] || 'auto';
+ value = _html2canvas.Util.trimText(value).split(' ');
+
+ if(attribute === 'backgroundSize' && (!value[0] || value[0].match(/cover|contain|auto/))) {
+ //these values will be handled in the parent function
+ } else {
+ value[0] = (value[0].indexOf( "%" ) === -1) ? toPX(element, attribute + "X", value[0]) : value[0];
+ if(value[1] === undefined) {
+ if(attribute === 'backgroundSize') {
+ value[1] = 'auto';
+ return value;
+ } else {
+ // IE 9 doesn't return double digit always
+ value[1] = value[0];
+ }
+ }
+ value[1] = (value[1].indexOf("%") === -1) ? toPX(element, attribute + "Y", value[1]) : value[1];
+ }
+ return value;
+ }
+
+ _html2canvas.Util.getCSS = function (element, attribute, index) {
+ if (previousElement !== element) {
+ computedCSS = document.defaultView.getComputedStyle(element, null);
+ }
+
+ var value = computedCSS[attribute];
+
+ if (/^background(Size|Position)$/.test(attribute)) {
+ return parseBackgroundSizePosition(value, element, attribute, index);
+ } else if (/border(Top|Bottom)(Left|Right)Radius/.test(attribute)) {
+ var arr = value.split(" ");
+ if (arr.length <= 1) {
+ arr[1] = arr[0];
+ }
+ return arr.map(asInt);
+ }
+
+ return value;
+ };
+
+ _html2canvas.Util.resizeBounds = function( current_width, current_height, target_width, target_height, stretch_mode ){
+ var target_ratio = target_width / target_height,
+ current_ratio = current_width / current_height,
+ output_width, output_height;
+
+ if(!stretch_mode || stretch_mode === 'auto') {
+ output_width = target_width;
+ output_height = target_height;
+ } else if(target_ratio < current_ratio ^ stretch_mode === 'contain') {
+ output_height = target_height;
+ output_width = target_height * current_ratio;
+ } else {
+ output_width = target_width;
+ output_height = target_width / current_ratio;
+ }
+
+ return {
+ width: output_width,
+ height: output_height
+ };
+ };
+
+ function backgroundBoundsFactory( prop, el, bounds, image, imageIndex, backgroundSize ) {
+ var bgposition = _html2canvas.Util.getCSS( el, prop, imageIndex ) ,
+ topPos,
+ left,
+ percentage,
+ val;
+
+ if (bgposition.length === 1){
+ val = bgposition[0];
+
+ bgposition = [];
+
+ bgposition[0] = val;
+ bgposition[1] = val;
+ }
+
+ if (bgposition[0].toString().indexOf("%") !== -1){
+ percentage = (parseFloat(bgposition[0])/100);
+ left = bounds.width * percentage;
+ if(prop !== 'backgroundSize') {
+ left -= (backgroundSize || image).width*percentage;
+ }
+ } else {
+ if(prop === 'backgroundSize') {
+ if(bgposition[0] === 'auto') {
+ left = image.width;
+ } else {
+ if (/contain|cover/.test(bgposition[0])) {
+ var resized = _html2canvas.Util.resizeBounds(image.width, image.height, bounds.width, bounds.height, bgposition[0]);
+ left = resized.width;
+ topPos = resized.height;
+ } else {
+ left = parseInt(bgposition[0], 10);
+ }
+ }
+ } else {
+ left = parseInt( bgposition[0], 10);
+ }
+ }
+
+
+ if(bgposition[1] === 'auto') {
+ topPos = left / image.width * image.height;
+ } else if (bgposition[1].toString().indexOf("%") !== -1){
+ percentage = (parseFloat(bgposition[1])/100);
+ topPos = bounds.height * percentage;
+ if(prop !== 'backgroundSize') {
+ topPos -= (backgroundSize || image).height * percentage;
+ }
+
+ } else {
+ topPos = parseInt(bgposition[1],10);
+ }
+
+ return [left, topPos];
+ }
+
+ _html2canvas.Util.BackgroundPosition = function( el, bounds, image, imageIndex, backgroundSize ) {
+ var result = backgroundBoundsFactory( 'backgroundPosition', el, bounds, image, imageIndex, backgroundSize );
+ return { left: result[0], top: result[1] };
+ };
+
+ _html2canvas.Util.BackgroundSize = function( el, bounds, image, imageIndex ) {
+ var result = backgroundBoundsFactory( 'backgroundSize', el, bounds, image, imageIndex );
+ return { width: result[0], height: result[1] };
+ };
+
+ _html2canvas.Util.Extend = function (options, defaults) {
+ for (var key in options) {
+ if (options.hasOwnProperty(key)) {
+ defaults[key] = options[key];
+ }
+ }
+ return defaults;
+ };
+
+
+ /*
+ * Derived from jQuery.contents()
+ * Copyright 2010, John Resig
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ */
+ _html2canvas.Util.Children = function( elem ) {
+ var children;
+ try {
+ children = (elem.nodeName && elem.nodeName.toUpperCase() === "IFRAME") ? elem.contentDocument || elem.contentWindow.document : (function(array) {
+ var ret = [];
+ if (array !== null) {
+ (function(first, second ) {
+ var i = first.length,
+ j = 0;
+
+ if (typeof second.length === "number") {
+ for (var l = second.length; j < l; j++) {
+ first[i++] = second[j];
+ }
+ } else {
+ while (second[j] !== undefined) {
+ first[i++] = second[j++];
+ }
+ }
+
+ first.length = i;
+
+ return first;
+ })(ret, array);
+ }
+ return ret;
+ })(elem.childNodes);
+
+ } catch (ex) {
+ _html2canvas.Util.log("html2canvas.Util.Children failed with exception: " + ex.message);
+ children = [];
+ }
+ return children;
+ };
+
+ _html2canvas.Util.isTransparent = function(backgroundColor) {
+ return (backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)" || backgroundColor === undefined);
+ };
+ _html2canvas.Util.Font = (function () {
+
+ var fontData = {};
+
+ return function(font, fontSize, doc) {
+ if (fontData[font + "-" + fontSize] !== undefined) {
+ return fontData[font + "-" + fontSize];
+ }
+
+ var container = doc.createElement('div'),
+ img = doc.createElement('img'),
+ span = doc.createElement('span'),
+ sampleText = 'Hidden Text',
+ baseline,
+ middle,
+ metricsObj;
+
+ container.style.visibility = "hidden";
+ container.style.fontFamily = font;
+ container.style.fontSize = fontSize;
+ container.style.margin = 0;
+ container.style.padding = 0;
+
+ doc.body.appendChild(container);
+
+ // http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever (handtinywhite.gif)
+ img.src = "data:image/gif;base64,R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=";
+ img.width = 1;
+ img.height = 1;
+
+ img.style.margin = 0;
+ img.style.padding = 0;
+ img.style.verticalAlign = "baseline";
+
+ span.style.fontFamily = font;
+ span.style.fontSize = fontSize;
+ span.style.margin = 0;
+ span.style.padding = 0;
+
+ span.appendChild(doc.createTextNode(sampleText));
+ container.appendChild(span);
+ container.appendChild(img);
+ baseline = (img.offsetTop - span.offsetTop) + 1;
+
+ container.removeChild(span);
+ container.appendChild(doc.createTextNode(sampleText));
+
+ container.style.lineHeight = "normal";
+ img.style.verticalAlign = "super";
+
+ middle = (img.offsetTop-container.offsetTop) + 1;
+ metricsObj = {
+ baseline: baseline,
+ lineWidth: 1,
+ middle: middle
+ };
+
+ fontData[font + "-" + fontSize] = metricsObj;
+
+ doc.body.removeChild(container);
+
+ return metricsObj;
+ };
+ })();
+
+ (function(){
+ var Util = _html2canvas.Util,
+ Generate = {};
+
+ _html2canvas.Generate = Generate;
+
+ var reGradients = [
+ /^(-webkit-linear-gradient)\(([a-z\s]+)([\w\d\.\s,%\(\)]+)\)$/,
+ /^(-o-linear-gradient)\(([a-z\s]+)([\w\d\.\s,%\(\)]+)\)$/,
+ /^(-webkit-gradient)\((linear|radial),\s((?:\d{1,3}%?)\s(?:\d{1,3}%?),\s(?:\d{1,3}%?)\s(?:\d{1,3}%?))([\w\d\.\s,%\(\)\-]+)\)$/,
+ /^(-moz-linear-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?))([\w\d\.\s,%\(\)]+)\)$/,
+ /^(-webkit-radial-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?)),\s(\w+)\s([a-z\-]+)([\w\d\.\s,%\(\)]+)\)$/,
+ /^(-moz-radial-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?)),\s(\w+)\s?([a-z\-]*)([\w\d\.\s,%\(\)]+)\)$/,
+ /^(-o-radial-gradient)\(((?:\d{1,3}%?)\s(?:\d{1,3}%?)),\s(\w+)\s([a-z\-]+)([\w\d\.\s,%\(\)]+)\)$/
+ ];
+
+ /*
+ * TODO: Add IE10 vendor prefix (-ms) support
+ * TODO: Add W3C gradient (linear-gradient) support
+ * TODO: Add old Webkit -webkit-gradient(radial, ...) support
+ * TODO: Maybe some RegExp optimizations are possible ;o)
+ */
+ Generate.parseGradient = function(css, bounds) {
+ var gradient, i, len = reGradients.length, m1, stop, m2, m2Len, step, m3, tl,tr,br,bl;
+
+ for(i = 0; i < len; i+=1){
+ m1 = css.match(reGradients[i]);
+ if(m1) {
+ break;
+ }
+ }
+
+ if(m1) {
+ switch(m1[1]) {
+ case '-webkit-linear-gradient':
+ case '-o-linear-gradient':
+
+ gradient = {
+ type: 'linear',
+ x0: null,
+ y0: null,
+ x1: null,
+ y1: null,
+ colorStops: []
+ };
+
+ // get coordinates
+ m2 = m1[2].match(/\w+/g);
+ if(m2){
+ m2Len = m2.length;
+ for(i = 0; i < m2Len; i+=1){
+ switch(m2[i]) {
+ case 'top':
+ gradient.y0 = 0;
+ gradient.y1 = bounds.height;
+ break;
+
+ case 'right':
+ gradient.x0 = bounds.width;
+ gradient.x1 = 0;
+ break;
+
+ case 'bottom':
+ gradient.y0 = bounds.height;
+ gradient.y1 = 0;
+ break;
+
+ case 'left':
+ gradient.x0 = 0;
+ gradient.x1 = bounds.width;
+ break;
+ }
+ }
+ }
+ if(gradient.x0 === null && gradient.x1 === null){ // center
+ gradient.x0 = gradient.x1 = bounds.width / 2;
+ }
+ if(gradient.y0 === null && gradient.y1 === null){ // center
+ gradient.y0 = gradient.y1 = bounds.height / 2;
+ }
+
+ // get colors and stops
+ m2 = m1[3].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)(?:\s\d{1,3}(?:%|px))?)+/g);
+ if(m2){
+ m2Len = m2.length;
+ step = 1 / Math.max(m2Len - 1, 1);
+ for(i = 0; i < m2Len; i+=1){
+ m3 = m2[i].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\s*(\d{1,3})?(%|px)?/);
+ if(m3[2]){
+ stop = parseFloat(m3[2]);
+ if(m3[3] === '%'){
+ stop /= 100;
+ } else { // px - stupid opera
+ stop /= bounds.width;
+ }
+ } else {
+ stop = i * step;
+ }
+ gradient.colorStops.push({
+ color: m3[1],
+ stop: stop
+ });
+ }
+ }
+ break;
+
+ case '-webkit-gradient':
+
+ gradient = {
+ type: m1[2] === 'radial' ? 'circle' : m1[2], // TODO: Add radial gradient support for older mozilla definitions
+ x0: 0,
+ y0: 0,
+ x1: 0,
+ y1: 0,
+ colorStops: []
+ };
+
+ // get coordinates
+ m2 = m1[3].match(/(\d{1,3})%?\s(\d{1,3})%?,\s(\d{1,3})%?\s(\d{1,3})%?/);
+ if(m2){
+ gradient.x0 = (m2[1] * bounds.width) / 100;
+ gradient.y0 = (m2[2] * bounds.height) / 100;
+ gradient.x1 = (m2[3] * bounds.width) / 100;
+ gradient.y1 = (m2[4] * bounds.height) / 100;
+ }
+
+ // get colors and stops
+ m2 = m1[4].match(/((?:from|to|color-stop)\((?:[0-9\.]+,\s)?(?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)\))+/g);
+ if(m2){
+ m2Len = m2.length;
+ for(i = 0; i < m2Len; i+=1){
+ m3 = m2[i].match(/(from|to|color-stop)\(([0-9\.]+)?(?:,\s)?((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\)/);
+ stop = parseFloat(m3[2]);
+ if(m3[1] === 'from') {
+ stop = 0.0;
+ }
+ if(m3[1] === 'to') {
+ stop = 1.0;
+ }
+ gradient.colorStops.push({
+ color: m3[3],
+ stop: stop
+ });
+ }
+ }
+ break;
+
+ case '-moz-linear-gradient':
+
+ gradient = {
+ type: 'linear',
+ x0: 0,
+ y0: 0,
+ x1: 0,
+ y1: 0,
+ colorStops: []
+ };
+
+ // get coordinates
+ m2 = m1[2].match(/(\d{1,3})%?\s(\d{1,3})%?/);
+
+ // m2[1] == 0% -> left
+ // m2[1] == 50% -> center
+ // m2[1] == 100% -> right
+
+ // m2[2] == 0% -> top
+ // m2[2] == 50% -> center
+ // m2[2] == 100% -> bottom
+
+ if(m2){
+ gradient.x0 = (m2[1] * bounds.width) / 100;
+ gradient.y0 = (m2[2] * bounds.height) / 100;
+ gradient.x1 = bounds.width - gradient.x0;
+ gradient.y1 = bounds.height - gradient.y0;
+ }
+
+ // get colors and stops
+ m2 = m1[3].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)(?:\s\d{1,3}%)?)+/g);
+ if(m2){
+ m2Len = m2.length;
+ step = 1 / Math.max(m2Len - 1, 1);
+ for(i = 0; i < m2Len; i+=1){
+ m3 = m2[i].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\s*(\d{1,3})?(%)?/);
+ if(m3[2]){
+ stop = parseFloat(m3[2]);
+ if(m3[3]){ // percentage
+ stop /= 100;
+ }
+ } else {
+ stop = i * step;
+ }
+ gradient.colorStops.push({
+ color: m3[1],
+ stop: stop
+ });
+ }
+ }
+ break;
+
+ case '-webkit-radial-gradient':
+ case '-moz-radial-gradient':
+ case '-o-radial-gradient':
+
+ gradient = {
+ type: 'circle',
+ x0: 0,
+ y0: 0,
+ x1: bounds.width,
+ y1: bounds.height,
+ cx: 0,
+ cy: 0,
+ rx: 0,
+ ry: 0,
+ colorStops: []
+ };
+
+ // center
+ m2 = m1[2].match(/(\d{1,3})%?\s(\d{1,3})%?/);
+ if(m2){
+ gradient.cx = (m2[1] * bounds.width) / 100;
+ gradient.cy = (m2[2] * bounds.height) / 100;
+ }
+
+ // size
+ m2 = m1[3].match(/\w+/);
+ m3 = m1[4].match(/[a-z\-]*/);
+ if(m2 && m3){
+ switch(m3[0]){
+ case 'farthest-corner':
+ case 'cover': // is equivalent to farthest-corner
+ case '': // mozilla removes "cover" from definition :(
+ tl = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.cy, 2));
+ tr = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2));
+ br = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2));
+ bl = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.cy, 2));
+ gradient.rx = gradient.ry = Math.max(tl, tr, br, bl);
+ break;
+ case 'closest-corner':
+ tl = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.cy, 2));
+ tr = Math.sqrt(Math.pow(gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2));
+ br = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.y1 - gradient.cy, 2));
+ bl = Math.sqrt(Math.pow(gradient.x1 - gradient.cx, 2) + Math.pow(gradient.cy, 2));
+ gradient.rx = gradient.ry = Math.min(tl, tr, br, bl);
+ break;
+ case 'farthest-side':
+ if(m2[0] === 'circle'){
+ gradient.rx = gradient.ry = Math.max(
+ gradient.cx,
+ gradient.cy,
+ gradient.x1 - gradient.cx,
+ gradient.y1 - gradient.cy
+ );
+ } else { // ellipse
+
+ gradient.type = m2[0];
+
+ gradient.rx = Math.max(
+ gradient.cx,
+ gradient.x1 - gradient.cx
+ );
+ gradient.ry = Math.max(
+ gradient.cy,
+ gradient.y1 - gradient.cy
+ );
+ }
+ break;
+ case 'closest-side':
+ case 'contain': // is equivalent to closest-side
+ if(m2[0] === 'circle'){
+ gradient.rx = gradient.ry = Math.min(
+ gradient.cx,
+ gradient.cy,
+ gradient.x1 - gradient.cx,
+ gradient.y1 - gradient.cy
+ );
+ } else { // ellipse
+
+ gradient.type = m2[0];
+
+ gradient.rx = Math.min(
+ gradient.cx,
+ gradient.x1 - gradient.cx
+ );
+ gradient.ry = Math.min(
+ gradient.cy,
+ gradient.y1 - gradient.cy
+ );
+ }
+ break;
+
+ // TODO: add support for "30px 40px" sizes (webkit only)
+ }
+ }
+
+ // color stops
+ m2 = m1[5].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\)(?:\s\d{1,3}(?:%|px))?)+/g);
+ if(m2){
+ m2Len = m2.length;
+ step = 1 / Math.max(m2Len - 1, 1);
+ for(i = 0; i < m2Len; i+=1){
+ m3 = m2[i].match(/((?:rgb|rgba)\(\d{1,3},\s\d{1,3},\s\d{1,3}(?:,\s[0-9\.]+)?\))\s*(\d{1,3})?(%|px)?/);
+ if(m3[2]){
+ stop = parseFloat(m3[2]);
+ if(m3[3] === '%'){
+ stop /= 100;
+ } else { // px - stupid opera
+ stop /= bounds.width;
+ }
+ } else {
+ stop = i * step;
+ }
+ gradient.colorStops.push({
+ color: m3[1],
+ stop: stop
+ });
+ }
+ }
+ break;
+ }
+ }
+
+ return gradient;
+ };
+
+ function addScrollStops(grad) {
+ return function(colorStop) {
+ try {
+ grad.addColorStop(colorStop.stop, colorStop.color);
+ }
+ catch(e) {
+ Util.log(['failed to add color stop: ', e, '; tried to add: ', colorStop]);
+ }
+ };
+ }
+
+ Generate.Gradient = function(src, bounds) {
+ if(bounds.width === 0 || bounds.height === 0) {
+ return;
+ }
+
+ var canvas = document.createElement('canvas'),
+ ctx = canvas.getContext('2d'),
+ gradient, grad;
+
+ canvas.width = bounds.width;
+ canvas.height = bounds.height;
+
+ // TODO: add support for multi defined background gradients
+ gradient = _html2canvas.Generate.parseGradient(src, bounds);
+
+ if(gradient) {
+ switch(gradient.type) {
+ case 'linear':
+ grad = ctx.createLinearGradient(gradient.x0, gradient.y0, gradient.x1, gradient.y1);
+ gradient.colorStops.forEach(addScrollStops(grad));
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, bounds.width, bounds.height);
+ break;
+
+ case 'circle':
+ grad = ctx.createRadialGradient(gradient.cx, gradient.cy, 0, gradient.cx, gradient.cy, gradient.rx);
+ gradient.colorStops.forEach(addScrollStops(grad));
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, bounds.width, bounds.height);
+ break;
+
+ case 'ellipse':
+ var canvasRadial = document.createElement('canvas'),
+ ctxRadial = canvasRadial.getContext('2d'),
+ ri = Math.max(gradient.rx, gradient.ry),
+ di = ri * 2;
+
+ canvasRadial.width = canvasRadial.height = di;
+
+ grad = ctxRadial.createRadialGradient(gradient.rx, gradient.ry, 0, gradient.rx, gradient.ry, ri);
+ gradient.colorStops.forEach(addScrollStops(grad));
+
+ ctxRadial.fillStyle = grad;
+ ctxRadial.fillRect(0, 0, di, di);
+
+ ctx.fillStyle = gradient.colorStops[gradient.colorStops.length - 1].color;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(canvasRadial, gradient.cx - gradient.rx, gradient.cy - gradient.ry, 2 * gradient.rx, 2 * gradient.ry);
+ break;
+ }
+ }
+
+ return canvas;
+ };
+
+ Generate.ListAlpha = function(number) {
+ var tmp = "",
+ modulus;
+
+ do {
+ modulus = number % 26;
+ tmp = String.fromCharCode((modulus) + 64) + tmp;
+ number = number / 26;
+ }while((number*26) > 26);
+
+ return tmp;
+ };
+
+ Generate.ListRoman = function(number) {
+ var romanArray = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"],
+ decimal = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1],
+ roman = "",
+ v,
+ len = romanArray.length;
+
+ if (number <= 0 || number >= 4000) {
+ return number;
+ }
+
+ for (v=0; v < len; v+=1) {
+ while (number >= decimal[v]) {
+ number -= decimal[v];
+ roman += romanArray[v];
+ }
+ }
+
+ return roman;
+ };
+ })();
+ function h2cRenderContext(width, height) {
+ var storage = [];
+ return {
+ storage: storage,
+ width: width,
+ height: height,
+ clip: function() {
+ storage.push({
+ type: "function",
+ name: "clip",
+ 'arguments': arguments
+ });
+ },
+ translate: function() {
+ storage.push({
+ type: "function",
+ name: "translate",
+ 'arguments': arguments
+ });
+ },
+ fill: function() {
+ storage.push({
+ type: "function",
+ name: "fill",
+ 'arguments': arguments
+ });
+ },
+ save: function() {
+ storage.push({
+ type: "function",
+ name: "save",
+ 'arguments': arguments
+ });
+ },
+ restore: function() {
+ storage.push({
+ type: "function",
+ name: "restore",
+ 'arguments': arguments
+ });
+ },
+ fillRect: function () {
+ storage.push({
+ type: "function",
+ name: "fillRect",
+ 'arguments': arguments
+ });
+ },
+ createPattern: function() {
+ storage.push({
+ type: "function",
+ name: "createPattern",
+ 'arguments': arguments
+ });
+ },
+ drawShape: function() {
+
+ var shape = [];
+
+ storage.push({
+ type: "function",
+ name: "drawShape",
+ 'arguments': shape
+ });
+
+ return {
+ moveTo: function() {
+ shape.push({
+ name: "moveTo",
+ 'arguments': arguments
+ });
+ },
+ lineTo: function() {
+ shape.push({
+ name: "lineTo",
+ 'arguments': arguments
+ });
+ },
+ arcTo: function() {
+ shape.push({
+ name: "arcTo",
+ 'arguments': arguments
+ });
+ },
+ bezierCurveTo: function() {
+ shape.push({
+ name: "bezierCurveTo",
+ 'arguments': arguments
+ });
+ },
+ quadraticCurveTo: function() {
+ shape.push({
+ name: "quadraticCurveTo",
+ 'arguments': arguments
+ });
+ }
+ };
+
+ },
+ drawImage: function () {
+ storage.push({
+ type: "function",
+ name: "drawImage",
+ 'arguments': arguments
+ });
+ },
+ fillText: function () {
+ storage.push({
+ type: "function",
+ name: "fillText",
+ 'arguments': arguments
+ });
+ },
+ setVariable: function (variable, value) {
+ storage.push({
+ type: "variable",
+ name: variable,
+ 'arguments': value
+ });
+ return value;
+ }
+ };
+ }
+ _html2canvas.Parse = function (images, options) {
+ window.scroll(0,0);
+
+ var element = (( options.elements === undefined ) ? document.body : options.elements[0]), // select body by default
+ numDraws = 0,
+ doc = element.ownerDocument,
+ Util = _html2canvas.Util,
+ support = Util.Support(options, doc),
+ ignoreElementsRegExp = new RegExp("(" + options.ignoreElements + ")"),
+ body = doc.body,
+ getCSS = Util.getCSS,
+ pseudoHide = "___html2canvas___pseudoelement",
+ hidePseudoElements = doc.createElement('style');
+
+ hidePseudoElements.innerHTML = '.' + pseudoHide + '-before:before { content: "" !important; display: none !important; }' +
+ '.' + pseudoHide + '-after:after { content: "" !important; display: none !important; }';
+
+ body.appendChild(hidePseudoElements);
+
+ images = images || {};
+
+ function documentWidth () {
+ return Math.max(
+ Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth),
+ Math.max(doc.body.offsetWidth, doc.documentElement.offsetWidth),
+ Math.max(doc.body.clientWidth, doc.documentElement.clientWidth)
+ );
+ }
+
+ function documentHeight () {
+ return Math.max(
+ Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight),
+ Math.max(doc.body.offsetHeight, doc.documentElement.offsetHeight),
+ Math.max(doc.body.clientHeight, doc.documentElement.clientHeight)
+ );
+ }
+
+ function getCSSInt(element, attribute) {
+ var val = parseInt(getCSS(element, attribute), 10);
+ return (isNaN(val)) ? 0 : val; // borders in old IE are throwing 'medium' for demo.html
+ }
+
+ function renderRect (ctx, x, y, w, h, bgcolor) {
+ if (bgcolor !== "transparent"){
+ ctx.setVariable("fillStyle", bgcolor);
+ ctx.fillRect(x, y, w, h);
+ numDraws+=1;
+ }
+ }
+
+ function capitalize(m, p1, p2) {
+ if (m.length > 0) {
+ return p1 + p2.toUpperCase();
+ }
+ }
+
+ function textTransform (text, transform) {
+ switch(transform){
+ case "lowercase":
+ return text.toLowerCase();
+ case "capitalize":
+ return text.replace( /(^|\s|:|-|\(|\))([a-z])/g, capitalize);
+ case "uppercase":
+ return text.toUpperCase();
+ default:
+ return text;
+ }
+ }
+
+ function noLetterSpacing(letter_spacing) {
+ return (/^(normal|none|0px)$/.test(letter_spacing));
+ }
+
+ function drawText(currentText, x, y, ctx){
+ if (currentText !== null && Util.trimText(currentText).length > 0) {
+ ctx.fillText(currentText, x, y);
+ numDraws+=1;
+ }
+ }
+
+ function setTextVariables(ctx, el, text_decoration, color) {
+ var align = false,
+ bold = getCSS(el, "fontWeight"),
+ family = getCSS(el, "fontFamily"),
+ size = getCSS(el, "fontSize"),
+ shadows = Util.parseTextShadows(getCSS(el, "textShadow"));
+
+ switch(parseInt(bold, 10)){
+ case 401:
+ bold = "bold";
+ break;
+ case 400:
+ bold = "normal";
+ break;
+ }
+
+ ctx.setVariable("fillStyle", color);
+ ctx.setVariable("font", [getCSS(el, "fontStyle"), getCSS(el, "fontVariant"), bold, size, family].join(" "));
+ ctx.setVariable("textAlign", (align) ? "right" : "left");
+
+ if (shadows.length) {
+ // TODO: support multiple text shadows
+ // apply the first text shadow
+ ctx.setVariable("shadowColor", shadows[0].color);
+ ctx.setVariable("shadowOffsetX", shadows[0].offsetX);
+ ctx.setVariable("shadowOffsetY", shadows[0].offsetY);
+ ctx.setVariable("shadowBlur", shadows[0].blur);
+ }
+
+ if (text_decoration !== "none"){
+ return Util.Font(family, size, doc);
+ }
+ }
+
+ function renderTextDecoration(ctx, text_decoration, bounds, metrics, color) {
+ switch(text_decoration) {
+ case "underline":
+ // Draws a line at the baseline of the font
+ // TODO As some browsers display the line as more than 1px if the font-size is big, need to take that into account both in position and size
+ renderRect(ctx, bounds.left, Math.round(bounds.top + metrics.baseline + metrics.lineWidth), bounds.width, 1, color);
+ break;
+ case "overline":
+ renderRect(ctx, bounds.left, Math.round(bounds.top), bounds.width, 1, color);
+ break;
+ case "line-through":
+ // TODO try and find exact position for line-through
+ renderRect(ctx, bounds.left, Math.ceil(bounds.top + metrics.middle + metrics.lineWidth), bounds.width, 1, color);
+ break;
+ }
+ }
+
+ function getTextBounds(state, text, textDecoration, isLast, transform) {
+ var bounds;
+ if (support.rangeBounds && !transform) {
+ if (textDecoration !== "none" || Util.trimText(text).length !== 0) {
+ bounds = textRangeBounds(text, state.node, state.textOffset);
+ }
+ state.textOffset += text.length;
+ } else if (state.node && typeof state.node.nodeValue === "string" ){
+ var newTextNode = (isLast) ? state.node.splitText(text.length) : null;
+ bounds = textWrapperBounds(state.node, transform);
+ state.node = newTextNode;
+ }
+ return bounds;
+ }
+
+ function textRangeBounds(text, textNode, textOffset) {
+ var range = doc.createRange();
+ range.setStart(textNode, textOffset);
+ range.setEnd(textNode, textOffset + text.length);
+ return range.getBoundingClientRect();
+ }
+
+ function textWrapperBounds(oldTextNode, transform) {
+ var parent = oldTextNode.parentNode,
+ wrapElement = doc.createElement('wrapper'),
+ backupText = oldTextNode.cloneNode(true);
+
+ wrapElement.appendChild(oldTextNode.cloneNode(true));
+ parent.replaceChild(wrapElement, oldTextNode);
+
+ var bounds = transform ? Util.OffsetBounds(wrapElement) : Util.Bounds(wrapElement);
+ parent.replaceChild(backupText, wrapElement);
+ return bounds;
+ }
+
+ function renderText(el, textNode, stack) {
+ var ctx = stack.ctx,
+ color = getCSS(el, "color"),
+ textDecoration = getCSS(el, "textDecoration"),
+ textAlign = getCSS(el, "textAlign"),
+ metrics,
+ textList,
+ state = {
+ node: textNode,
+ textOffset: 0
+ };
+
+ if (Util.trimText(textNode.nodeValue).length > 0) {
+ textNode.nodeValue = textTransform(textNode.nodeValue, getCSS(el, "textTransform"));
+ textAlign = textAlign.replace(["-webkit-auto"],["auto"]);
+
+ textList = (!options.letterRendering && /^(left|right|justify|auto)$/.test(textAlign) && noLetterSpacing(getCSS(el, "letterSpacing"))) ?
+ textNode.nodeValue.split(/(\b| )/)
+ : textNode.nodeValue.split("");
+
+ metrics = setTextVariables(ctx, el, textDecoration, color);
+
+ if (options.chinese) {
+ textList.forEach(function(word, index) {
+ if (/.*[\u4E00-\u9FA5].*$/.test(word)) {
+ word = word.split("");
+ word.unshift(index, 1);
+ textList.splice.apply(textList, word);
+ }
+ });
+ }
+
+ textList.forEach(function(text, index) {
+ var bounds = getTextBounds(state, text, textDecoration, (index < textList.length - 1), stack.transform.matrix);
+ if (bounds) {
+ drawText(text, bounds.left, bounds.bottom, ctx);
+ renderTextDecoration(ctx, textDecoration, bounds, metrics, color);
+ }
+ });
+ }
+ }
+
+ function listPosition (element, val) {
+ var boundElement = doc.createElement( "boundelement" ),
+ originalType,
+ bounds;
+
+ boundElement.style.display = "inline";
+
+ originalType = element.style.listStyleType;
+ element.style.listStyleType = "none";
+
+ boundElement.appendChild(doc.createTextNode(val));
+
+ element.insertBefore(boundElement, element.firstChild);
+
+ bounds = Util.Bounds(boundElement);
+ element.removeChild(boundElement);
+ element.style.listStyleType = originalType;
+ return bounds;
+ }
+
+ function elementIndex(el) {
+ var i = -1,
+ count = 1,
+ childs = el.parentNode.childNodes;
+
+ if (el.parentNode) {
+ while(childs[++i] !== el) {
+ if (childs[i].nodeType === 1) {
+ count++;
+ }
+ }
+ return count;
+ } else {
+ return -1;
+ }
+ }
+
+ function listItemText(element, type) {
+ var currentIndex = elementIndex(element), text;
+ switch(type){
+ case "decimal":
+ text = currentIndex;
+ break;
+ case "decimal-leading-zero":
+ text = (currentIndex.toString().length === 1) ? currentIndex = "0" + currentIndex.toString() : currentIndex.toString();
+ break;
+ case "upper-roman":
+ text = _html2canvas.Generate.ListRoman( currentIndex );
+ break;
+ case "lower-roman":
+ text = _html2canvas.Generate.ListRoman( currentIndex ).toLowerCase();
+ break;
+ case "lower-alpha":
+ text = _html2canvas.Generate.ListAlpha( currentIndex ).toLowerCase();
+ break;
+ case "upper-alpha":
+ text = _html2canvas.Generate.ListAlpha( currentIndex );
+ break;
+ }
+
+ return text + ". ";
+ }
+
+ function renderListItem(element, stack, elBounds) {
+ var x,
+ text,
+ ctx = stack.ctx,
+ type = getCSS(element, "listStyleType"),
+ listBounds;
+
+ if (/^(decimal|decimal-leading-zero|upper-alpha|upper-latin|upper-roman|lower-alpha|lower-greek|lower-latin|lower-roman)$/i.test(type)) {
+ text = listItemText(element, type);
+ listBounds = listPosition(element, text);
+ setTextVariables(ctx, element, "none", getCSS(element, "color"));
+
+ if (getCSS(element, "listStylePosition") === "inside") {
+ ctx.setVariable("textAlign", "left");
+ x = elBounds.left;
+ } else {
+ return;
+ }
+
+ drawText(text, x, listBounds.bottom, ctx);
+ }
+ }
+
+ function loadImage (src){
+ var img = images[src];
+ return (img && img.succeeded === true) ? img.img : false;
+ }
+
+ function clipBounds(src, dst){
+ var x = Math.max(src.left, dst.left),
+ y = Math.max(src.top, dst.top),
+ x2 = Math.min((src.left + src.width), (dst.left + dst.width)),
+ y2 = Math.min((src.top + src.height), (dst.top + dst.height));
+
+ return {
+ left:x,
+ top:y,
+ width:x2-x,
+ height:y2-y
+ };
+ }
+
+ function setZ(element, stack, parentStack){
+ var newContext,
+ isPositioned = stack.cssPosition !== 'static',
+ zIndex = isPositioned ? getCSS(element, 'zIndex') : 'auto',
+ opacity = getCSS(element, 'opacity'),
+ isFloated = getCSS(element, 'cssFloat') !== 'none';
+
+ // https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Understanding_z_index/The_stacking_context
+ // When a new stacking context should be created:
+ // the root element (HTML),
+ // positioned (absolutely or relatively) with a z-index value other than "auto",
+ // elements with an opacity value less than 1. (See the specification for opacity),
+ // on mobile WebKit and Chrome 22+, position: fixed always creates a new stacking context, even when z-index is "auto" (See this post)
+
+ stack.zIndex = newContext = h2czContext(zIndex);
+ newContext.isPositioned = isPositioned;
+ newContext.isFloated = isFloated;
+ newContext.opacity = opacity;
+ newContext.ownStacking = (zIndex !== 'auto' || opacity < 1);
+
+ if (parentStack) {
+ parentStack.zIndex.children.push(stack);
+ }
+ }
+
+ function renderImage(ctx, element, image, bounds, borders) {
+
+ var paddingLeft = getCSSInt(element, 'paddingLeft'),
+ paddingTop = getCSSInt(element, 'paddingTop'),
+ paddingRight = getCSSInt(element, 'paddingRight'),
+ paddingBottom = getCSSInt(element, 'paddingBottom');
+
+ drawImage(
+ ctx,
+ image,
+ 0, //sx
+ 0, //sy
+ image.width, //sw
+ image.height, //sh
+ bounds.left + paddingLeft + borders[3].width, //dx
+ bounds.top + paddingTop + borders[0].width, // dy
+ bounds.width - (borders[1].width + borders[3].width + paddingLeft + paddingRight), //dw
+ bounds.height - (borders[0].width + borders[2].width + paddingTop + paddingBottom) //dh
+ );
+ }
+
+ function getBorderData(element) {
+ return ["Top", "Right", "Bottom", "Left"].map(function(side) {
+ return {
+ width: getCSSInt(element, 'border' + side + 'Width'),
+ color: getCSS(element, 'border' + side + 'Color')
+ };
+ });
+ }
+
+ function getBorderRadiusData(element) {
+ return ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].map(function(side) {
+ return getCSS(element, 'border' + side + 'Radius');
+ });
+ }
+
+ var getCurvePoints = (function(kappa) {
+
+ return function(x, y, r1, r2) {
+ var ox = (r1) * kappa, // control point offset horizontal
+ oy = (r2) * kappa, // control point offset vertical
+ xm = x + r1, // x-middle
+ ym = y + r2; // y-middle
+ return {
+ topLeft: bezierCurve({
+ x:x,
+ y:ym
+ }, {
+ x:x,
+ y:ym - oy
+ }, {
+ x:xm - ox,
+ y:y
+ }, {
+ x:xm,
+ y:y
+ }),
+ topRight: bezierCurve({
+ x:x,
+ y:y
+ }, {
+ x:x + ox,
+ y:y
+ }, {
+ x:xm,
+ y:ym - oy
+ }, {
+ x:xm,
+ y:ym
+ }),
+ bottomRight: bezierCurve({
+ x:xm,
+ y:y
+ }, {
+ x:xm,
+ y:y + oy
+ }, {
+ x:x + ox,
+ y:ym
+ }, {
+ x:x,
+ y:ym
+ }),
+ bottomLeft: bezierCurve({
+ x:xm,
+ y:ym
+ }, {
+ x:xm - ox,
+ y:ym
+ }, {
+ x:x,
+ y:y + oy
+ }, {
+ x:x,
+ y:y
+ })
+ };
+ };
+ })(4 * ((Math.sqrt(2) - 1) / 3));
+
+ function bezierCurve(start, startControl, endControl, end) {
+
+ var lerp = function (a, b, t) {
+ return {
+ x:a.x + (b.x - a.x) * t,
+ y:a.y + (b.y - a.y) * t
+ };
+ };
+
+ return {
+ start: start,
+ startControl: startControl,
+ endControl: endControl,
+ end: end,
+ subdivide: function(t) {
+ var ab = lerp(start, startControl, t),
+ bc = lerp(startControl, endControl, t),
+ cd = lerp(endControl, end, t),
+ abbc = lerp(ab, bc, t),
+ bccd = lerp(bc, cd, t),
+ dest = lerp(abbc, bccd, t);
+ return [bezierCurve(start, ab, abbc, dest), bezierCurve(dest, bccd, cd, end)];
+ },
+ curveTo: function(borderArgs) {
+ borderArgs.push(["bezierCurve", startControl.x, startControl.y, endControl.x, endControl.y, end.x, end.y]);
+ },
+ curveToReversed: function(borderArgs) {
+ borderArgs.push(["bezierCurve", endControl.x, endControl.y, startControl.x, startControl.y, start.x, start.y]);
+ }
+ };
+ }
+
+ function parseCorner(borderArgs, radius1, radius2, corner1, corner2, x, y) {
+ if (radius1[0] > 0 || radius1[1] > 0) {
+ borderArgs.push(["line", corner1[0].start.x, corner1[0].start.y]);
+ corner1[0].curveTo(borderArgs);
+ corner1[1].curveTo(borderArgs);
+ } else {
+ borderArgs.push(["line", x, y]);
+ }
+
+ if (radius2[0] > 0 || radius2[1] > 0) {
+ borderArgs.push(["line", corner2[0].start.x, corner2[0].start.y]);
+ }
+ }
+
+ function drawSide(borderData, radius1, radius2, outer1, inner1, outer2, inner2) {
+ var borderArgs = [];
+
+ if (radius1[0] > 0 || radius1[1] > 0) {
+ borderArgs.push(["line", outer1[1].start.x, outer1[1].start.y]);
+ outer1[1].curveTo(borderArgs);
+ } else {
+ borderArgs.push([ "line", borderData.c1[0], borderData.c1[1]]);
+ }
+
+ if (radius2[0] > 0 || radius2[1] > 0) {
+ borderArgs.push(["line", outer2[0].start.x, outer2[0].start.y]);
+ outer2[0].curveTo(borderArgs);
+ borderArgs.push(["line", inner2[0].end.x, inner2[0].end.y]);
+ inner2[0].curveToReversed(borderArgs);
+ } else {
+ borderArgs.push([ "line", borderData.c2[0], borderData.c2[1]]);
+ borderArgs.push([ "line", borderData.c3[0], borderData.c3[1]]);
+ }
+
+ if (radius1[0] > 0 || radius1[1] > 0) {
+ borderArgs.push(["line", inner1[1].end.x, inner1[1].end.y]);
+ inner1[1].curveToReversed(borderArgs);
+ } else {
+ borderArgs.push([ "line", borderData.c4[0], borderData.c4[1]]);
+ }
+
+ return borderArgs;
+ }
+
+ function calculateCurvePoints(bounds, borderRadius, borders) {
+
+ var x = bounds.left,
+ y = bounds.top,
+ width = bounds.width,
+ height = bounds.height,
+
+ tlh = borderRadius[0][0],
+ tlv = borderRadius[0][1],
+ trh = borderRadius[1][0],
+ trv = borderRadius[1][1],
+ brh = borderRadius[2][0],
+ brv = borderRadius[2][1],
+ blh = borderRadius[3][0],
+ blv = borderRadius[3][1],
+
+ topWidth = width - trh,
+ rightHeight = height - brv,
+ bottomWidth = width - brh,
+ leftHeight = height - blv;
+
+ return {
+ topLeftOuter: getCurvePoints(
+ x,
+ y,
+ tlh,
+ tlv
+ ).topLeft.subdivide(0.5),
+
+ topLeftInner: getCurvePoints(
+ x + borders[3].width,
+ y + borders[0].width,
+ Math.max(0, tlh - borders[3].width),
+ Math.max(0, tlv - borders[0].width)
+ ).topLeft.subdivide(0.5),
+
+ topRightOuter: getCurvePoints(
+ x + topWidth,
+ y,
+ trh,
+ trv
+ ).topRight.subdivide(0.5),
+
+ topRightInner: getCurvePoints(
+ x + Math.min(topWidth, width + borders[3].width),
+ y + borders[0].width,
+ (topWidth > width + borders[3].width) ? 0 :trh - borders[3].width,
+ trv - borders[0].width
+ ).topRight.subdivide(0.5),
+
+ bottomRightOuter: getCurvePoints(
+ x + bottomWidth,
+ y + rightHeight,
+ brh,
+ brv
+ ).bottomRight.subdivide(0.5),
+
+ bottomRightInner: getCurvePoints(
+ x + Math.min(bottomWidth, width + borders[3].width),
+ y + Math.min(rightHeight, height + borders[0].width),
+ Math.max(0, brh - borders[1].width),
+ Math.max(0, brv - borders[2].width)
+ ).bottomRight.subdivide(0.5),
+
+ bottomLeftOuter: getCurvePoints(
+ x,
+ y + leftHeight,
+ blh,
+ blv
+ ).bottomLeft.subdivide(0.5),
+
+ bottomLeftInner: getCurvePoints(
+ x + borders[3].width,
+ y + leftHeight,
+ Math.max(0, blh - borders[3].width),
+ Math.max(0, blv - borders[2].width)
+ ).bottomLeft.subdivide(0.5)
+ };
+ }
+
+ function getBorderClip(element, borderPoints, borders, radius, bounds) {
+ var backgroundClip = getCSS(element, 'backgroundClip'),
+ borderArgs = [];
+
+ switch(backgroundClip) {
+ case "content-box":
+ case "padding-box":
+ parseCorner(borderArgs, radius[0], radius[1], borderPoints.topLeftInner, borderPoints.topRightInner, bounds.left + borders[3].width, bounds.top + borders[0].width);
+ parseCorner(borderArgs, radius[1], radius[2], borderPoints.topRightInner, borderPoints.bottomRightInner, bounds.left + bounds.width - borders[1].width, bounds.top + borders[0].width);
+ parseCorner(borderArgs, radius[2], radius[3], borderPoints.bottomRightInner, borderPoints.bottomLeftInner, bounds.left + bounds.width - borders[1].width, bounds.top + bounds.height - borders[2].width);
+ parseCorner(borderArgs, radius[3], radius[0], borderPoints.bottomLeftInner, borderPoints.topLeftInner, bounds.left + borders[3].width, bounds.top + bounds.height - borders[2].width);
+ break;
+
+ default:
+ parseCorner(borderArgs, radius[0], radius[1], borderPoints.topLeftOuter, borderPoints.topRightOuter, bounds.left, bounds.top);
+ parseCorner(borderArgs, radius[1], radius[2], borderPoints.topRightOuter, borderPoints.bottomRightOuter, bounds.left + bounds.width, bounds.top);
+ parseCorner(borderArgs, radius[2], radius[3], borderPoints.bottomRightOuter, borderPoints.bottomLeftOuter, bounds.left + bounds.width, bounds.top + bounds.height);
+ parseCorner(borderArgs, radius[3], radius[0], borderPoints.bottomLeftOuter, borderPoints.topLeftOuter, bounds.left, bounds.top + bounds.height);
+ break;
+ }
+
+ return borderArgs;
+ }
+
+ function parseBorders(element, bounds, borders){
+ var x = bounds.left,
+ y = bounds.top,
+ width = bounds.width,
+ height = bounds.height,
+ borderSide,
+ bx,
+ by,
+ bw,
+ bh,
+ borderArgs,
+ // http://www.w3.org/TR/css3-background/#the-border-radius
+ borderRadius = getBorderRadiusData(element),
+ borderPoints = calculateCurvePoints(bounds, borderRadius, borders),
+ borderData = {
+ clip: getBorderClip(element, borderPoints, borders, borderRadius, bounds),
+ borders: []
+ };
+
+ for (borderSide = 0; borderSide < 4; borderSide++) {
+
+ if (borders[borderSide].width > 0) {
+ bx = x;
+ by = y;
+ bw = width;
+ bh = height - (borders[2].width);
+
+ switch(borderSide) {
+ case 0:
+ // top border
+ bh = borders[0].width;
+
+ borderArgs = drawSide({
+ c1: [bx, by],
+ c2: [bx + bw, by],
+ c3: [bx + bw - borders[1].width, by + bh],
+ c4: [bx + borders[3].width, by + bh]
+ }, borderRadius[0], borderRadius[1],
+ borderPoints.topLeftOuter, borderPoints.topLeftInner, borderPoints.topRightOuter, borderPoints.topRightInner);
+ break;
+ case 1:
+ // right border
+ bx = x + width - (borders[1].width);
+ bw = borders[1].width;
+
+ borderArgs = drawSide({
+ c1: [bx + bw, by],
+ c2: [bx + bw, by + bh + borders[2].width],
+ c3: [bx, by + bh],
+ c4: [bx, by + borders[0].width]
+ }, borderRadius[1], borderRadius[2],
+ borderPoints.topRightOuter, borderPoints.topRightInner, borderPoints.bottomRightOuter, borderPoints.bottomRightInner);
+ break;
+ case 2:
+ // bottom border
+ by = (by + height) - (borders[2].width);
+ bh = borders[2].width;
+
+ borderArgs = drawSide({
+ c1: [bx + bw, by + bh],
+ c2: [bx, by + bh],
+ c3: [bx + borders[3].width, by],
+ c4: [bx + bw - borders[3].width, by]
+ }, borderRadius[2], borderRadius[3],
+ borderPoints.bottomRightOuter, borderPoints.bottomRightInner, borderPoints.bottomLeftOuter, borderPoints.bottomLeftInner);
+ break;
+ case 3:
+ // left border
+ bw = borders[3].width;
+
+ borderArgs = drawSide({
+ c1: [bx, by + bh + borders[2].width],
+ c2: [bx, by],
+ c3: [bx + bw, by + borders[0].width],
+ c4: [bx + bw, by + bh]
+ }, borderRadius[3], borderRadius[0],
+ borderPoints.bottomLeftOuter, borderPoints.bottomLeftInner, borderPoints.topLeftOuter, borderPoints.topLeftInner);
+ break;
+ }
+
+ borderData.borders.push({
+ args: borderArgs,
+ color: borders[borderSide].color
+ });
+
+ }
+ }
+
+ return borderData;
+ }
+
+ function createShape(ctx, args) {
+ var shape = ctx.drawShape();
+ args.forEach(function(border, index) {
+ shape[(index === 0) ? "moveTo" : border[0] + "To" ].apply(null, border.slice(1));
+ });
+ return shape;
+ }
+
+ function renderBorders(ctx, borderArgs, color) {
+ if (color !== "transparent") {
+ ctx.setVariable( "fillStyle", color);
+ createShape(ctx, borderArgs);
+ ctx.fill();
+ numDraws+=1;
+ }
+ }
+
+ function renderFormValue (el, bounds, stack){
+
+ var valueWrap = doc.createElement('valuewrap'),
+ cssPropertyArray = ['lineHeight','textAlign','fontFamily','color','fontSize','paddingLeft','paddingTop','width','height','border','borderLeftWidth','borderTopWidth'],
+ textValue,
+ textNode;
+
+ cssPropertyArray.forEach(function(property) {
+ try {
+ valueWrap.style[property] = getCSS(el, property);
+ } catch(e) {
+ // Older IE has issues with "border"
+ Util.log("html2canvas: Parse: Exception caught in renderFormValue: " + e.message);
+ }
+ });
+
+ valueWrap.style.borderColor = "black";
+ valueWrap.style.borderStyle = "solid";
+ valueWrap.style.display = "block";
+ valueWrap.style.position = "absolute";
+
+ if (/^(submit|reset|button|text|password)$/.test(el.type) || el.nodeName === "SELECT"){
+ valueWrap.style.lineHeight = getCSS(el, "height");
+ }
+
+ valueWrap.style.top = bounds.top + "px";
+ valueWrap.style.left = bounds.left + "px";
+
+ textValue = (el.nodeName === "SELECT") ? (el.options[el.selectedIndex] || 0).text : el.value;
+ if(!textValue) {
+ textValue = el.placeholder;
+ }
+
+ textNode = doc.createTextNode(textValue);
+
+ valueWrap.appendChild(textNode);
+ body.appendChild(valueWrap);
+
+ renderText(el, textNode, stack);
+ body.removeChild(valueWrap);
+ }
+
+ function drawImage (ctx) {
+ ctx.drawImage.apply(ctx, Array.prototype.slice.call(arguments, 1));
+ numDraws+=1;
+ }
+
+ function getPseudoElement(el, which) {
+ var elStyle = window.getComputedStyle(el, which);
+ if(!elStyle || !elStyle.content || elStyle.content === "none" || elStyle.content === "-moz-alt-content" || elStyle.display === "none") {
+ return;
+ }
+ var content = elStyle.content + '',
+ first = content.substr( 0, 1 );
+ //strips quotes
+ if(first === content.substr( content.length - 1 ) && first.match(/'|"/)) {
+ content = content.substr( 1, content.length - 2 );
+ }
+
+ var isImage = content.substr( 0, 3 ) === 'url',
+ elps = document.createElement( isImage ? 'img' : 'span' );
+
+ elps.className = pseudoHide + "-before " + pseudoHide + "-after";
+
+ Object.keys(elStyle).filter(indexedProperty).forEach(function(prop) {
+ // Prevent assigning of read only CSS Rules, ex. length, parentRule
+ try {
+ elps.style[prop] = elStyle[prop];
+ } catch (e) {
+ Util.log(['Tried to assign readonly property ', prop, 'Error:', e]);
+ }
+ });
+
+ if(isImage) {
+ elps.src = Util.parseBackgroundImage(content)[0].args[0];
+ } else {
+ elps.innerHTML = content;
+ }
+ return elps;
+ }
+
+ function indexedProperty(property) {
+ return (isNaN(window.parseInt(property, 10)));
+ }
+
+ function injectPseudoElements(el, stack) {
+ var before = getPseudoElement(el, ':before'),
+ after = getPseudoElement(el, ':after');
+ if(!before && !after) {
+ return;
+ }
+
+ if(before) {
+ el.className += " " + pseudoHide + "-before";
+ el.parentNode.insertBefore(before, el);
+ parseElement(before, stack, true);
+ el.parentNode.removeChild(before);
+ el.className = el.className.replace(pseudoHide + "-before", "").trim();
+ }
+
+ if (after) {
+ el.className += " " + pseudoHide + "-after";
+ el.appendChild(after);
+ parseElement(after, stack, true);
+ el.removeChild(after);
+ el.className = el.className.replace(pseudoHide + "-after", "").trim();
+ }
+
+ }
+
+ function renderBackgroundRepeat(ctx, image, backgroundPosition, bounds) {
+ var offsetX = Math.round(bounds.left + backgroundPosition.left),
+ offsetY = Math.round(bounds.top + backgroundPosition.top);
+
+ ctx.createPattern(image);
+ ctx.translate(offsetX, offsetY);
+ ctx.fill();
+ ctx.translate(-offsetX, -offsetY);
+ }
+
+ function backgroundRepeatShape(ctx, image, backgroundPosition, bounds, left, top, width, height) {
+ var args = [];
+ args.push(["line", Math.round(left), Math.round(top)]);
+ args.push(["line", Math.round(left + width), Math.round(top)]);
+ args.push(["line", Math.round(left + width), Math.round(height + top)]);
+ args.push(["line", Math.round(left), Math.round(height + top)]);
+ createShape(ctx, args);
+ ctx.save();
+ ctx.clip();
+ renderBackgroundRepeat(ctx, image, backgroundPosition, bounds);
+ ctx.restore();
+ }
+
+ function renderBackgroundColor(ctx, backgroundBounds, bgcolor) {
+ renderRect(
+ ctx,
+ backgroundBounds.left,
+ backgroundBounds.top,
+ backgroundBounds.width,
+ backgroundBounds.height,
+ bgcolor
+ );
+ }
+
+ function renderBackgroundRepeating(el, bounds, ctx, image, imageIndex) {
+ var backgroundSize = Util.BackgroundSize(el, bounds, image, imageIndex),
+ backgroundPosition = Util.BackgroundPosition(el, bounds, image, imageIndex, backgroundSize),
+ backgroundRepeat = getCSS(el, "backgroundRepeat").split(",").map(Util.trimText);
+
+ image = resizeImage(image, backgroundSize);
+
+ backgroundRepeat = backgroundRepeat[imageIndex] || backgroundRepeat[0];
+
+ switch (backgroundRepeat) {
+ case "repeat-x":
+ backgroundRepeatShape(ctx, image, backgroundPosition, bounds,
+ bounds.left, bounds.top + backgroundPosition.top, 99999, image.height);
+ break;
+
+ case "repeat-y":
+ backgroundRepeatShape(ctx, image, backgroundPosition, bounds,
+ bounds.left + backgroundPosition.left, bounds.top, image.width, 99999);
+ break;
+
+ case "no-repeat":
+ backgroundRepeatShape(ctx, image, backgroundPosition, bounds,
+ bounds.left + backgroundPosition.left, bounds.top + backgroundPosition.top, image.width, image.height);
+ break;
+
+ default:
+ renderBackgroundRepeat(ctx, image, backgroundPosition, {
+ top: bounds.top,
+ left: bounds.left,
+ width: image.width,
+ height: image.height
+ });
+ break;
+ }
+ }
+
+ function renderBackgroundImage(element, bounds, ctx) {
+ var backgroundImage = getCSS(element, "backgroundImage"),
+ backgroundImages = Util.parseBackgroundImage(backgroundImage),
+ image,
+ imageIndex = backgroundImages.length;
+
+ while(imageIndex--) {
+ backgroundImage = backgroundImages[imageIndex];
+
+ if (!backgroundImage.args || backgroundImage.args.length === 0) {
+ continue;
+ }
+
+ var key = backgroundImage.method === 'url' ?
+ backgroundImage.args[0] :
+ backgroundImage.value;
+
+ image = loadImage(key);
+
+ // TODO add support for background-origin
+ if (image) {
+ renderBackgroundRepeating(element, bounds, ctx, image, imageIndex);
+ } else {
+ Util.log("html2canvas: Error loading background:", backgroundImage);
+ }
+ }
+ }
+
+ function resizeImage(image, bounds) {
+ if(image.width === bounds.width && image.height === bounds.height) {
+ return image;
+ }
+
+ var ctx, canvas = doc.createElement('canvas');
+ canvas.width = bounds.width;
+ canvas.height = bounds.height;
+ ctx = canvas.getContext("2d");
+ drawImage(ctx, image, 0, 0, image.width, image.height, 0, 0, bounds.width, bounds.height );
+ return canvas;
+ }
+
+ function setOpacity(ctx, element, parentStack) {
+ return ctx.setVariable("globalAlpha", getCSS(element, "opacity") * ((parentStack) ? parentStack.opacity : 1));
+ }
+
+ function removePx(str) {
+ return str.replace("px", "");
+ }
+
+ var transformRegExp = /(matrix)\((.+)\)/;
+
+ function getTransform(element, parentStack) {
+ var transform = getCSS(element, "transform") || getCSS(element, "-webkit-transform") || getCSS(element, "-moz-transform") || getCSS(element, "-ms-transform") || getCSS(element, "-o-transform");
+ var transformOrigin = getCSS(element, "transform-origin") || getCSS(element, "-webkit-transform-origin") || getCSS(element, "-moz-transform-origin") || getCSS(element, "-ms-transform-origin") || getCSS(element, "-o-transform-origin") || "0px 0px";
+
+ transformOrigin = transformOrigin.split(" ").map(removePx).map(Util.asFloat);
+
+ var matrix;
+ if (transform && transform !== "none") {
+ var match = transform.match(transformRegExp);
+ if (match) {
+ switch(match[1]) {
+ case "matrix":
+ matrix = match[2].split(",").map(Util.trimText).map(Util.asFloat);
+ break;
+ }
+ }
+ }
+
+ return {
+ origin: transformOrigin,
+ matrix: matrix
+ };
+ }
+
+ function createStack(element, parentStack, bounds, transform) {
+ var ctx = h2cRenderContext((!parentStack) ? documentWidth() : bounds.width , (!parentStack) ? documentHeight() : bounds.height),
+ stack = {
+ ctx: ctx,
+ opacity: setOpacity(ctx, element, parentStack),
+ cssPosition: getCSS(element, "position"),
+ borders: getBorderData(element),
+ transform: transform,
+ clip: (parentStack && parentStack.clip) ? Util.Extend( {}, parentStack.clip ) : null
+ };
+
+ setZ(element, stack, parentStack);
+
+ // TODO correct overflow for absolute content residing under a static position
+ if (options.useOverflow === true && /(hidden|scroll|auto)/.test(getCSS(element, "overflow")) === true && /(BODY)/i.test(element.nodeName) === false){
+ stack.clip = (stack.clip) ? clipBounds(stack.clip, bounds) : bounds;
+ }
+
+ return stack;
+ }
+
+ function getBackgroundBounds(borders, bounds, clip) {
+ var backgroundBounds = {
+ left: bounds.left + borders[3].width,
+ top: bounds.top + borders[0].width,
+ width: bounds.width - (borders[1].width + borders[3].width),
+ height: bounds.height - (borders[0].width + borders[2].width)
+ };
+
+ if (clip) {
+ backgroundBounds = clipBounds(backgroundBounds, clip);
+ }
+
+ return backgroundBounds;
+ }
+
+ function getBounds(element, transform) {
+ var bounds = (transform.matrix) ? Util.OffsetBounds(element) : Util.Bounds(element);
+ transform.origin[0] += bounds.left;
+ transform.origin[1] += bounds.top;
+ return bounds;
+ }
+
+ function renderElement(element, parentStack, pseudoElement, ignoreBackground) {
+ var transform = getTransform(element, parentStack),
+ bounds = getBounds(element, transform),
+ image,
+ stack = createStack(element, parentStack, bounds, transform),
+ borders = stack.borders,
+ ctx = stack.ctx,
+ backgroundBounds = getBackgroundBounds(borders, bounds, stack.clip),
+ borderData = parseBorders(element, bounds, borders),
+ backgroundColor = (ignoreElementsRegExp.test(element.nodeName)) ? "#efefef" : getCSS(element, "backgroundColor");
+
+
+ createShape(ctx, borderData.clip);
+
+ ctx.save();
+ ctx.clip();
+
+ if (backgroundBounds.height > 0 && backgroundBounds.width > 0 && !ignoreBackground) {
+ renderBackgroundColor(ctx, bounds, backgroundColor);
+ renderBackgroundImage(element, backgroundBounds, ctx);
+ } else if (ignoreBackground) {
+ stack.backgroundColor = backgroundColor;
+ }
+
+ ctx.restore();
+
+ borderData.borders.forEach(function(border) {
+ renderBorders(ctx, border.args, border.color);
+ });
+
+ if (!pseudoElement) {
+ injectPseudoElements(element, stack);
+ }
+
+ switch(element.nodeName){
+ case "IMG":
+ if ((image = loadImage(element.getAttribute('src')))) {
+ renderImage(ctx, element, image, bounds, borders);
+ } else {
+ Util.log("html2canvas: Error loading <img>:" + element.getAttribute('src'));
+ }
+ break;
+ case "INPUT":
+ // TODO add all relevant type's, i.e. HTML5 new stuff
+ // todo add support for placeholder attribute for browsers which support it
+ if (/^(text|url|email|submit|button|reset)$/.test(element.type) && (element.value || element.placeholder || "").length > 0){
+ renderFormValue(element, bounds, stack);
+ }
+ break;
+ case "TEXTAREA":
+ if ((element.value || element.placeholder || "").length > 0){
+ renderFormValue(element, bounds, stack);
+ }
+ break;
+ case "SELECT":
+ if ((element.options||element.placeholder || "").length > 0){
+ renderFormValue(element, bounds, stack);
+ }
+ break;
+ case "LI":
+ renderListItem(element, stack, backgroundBounds);
+ break;
+ case "CANVAS":
+ renderImage(ctx, element, element, bounds, borders);
+ break;
+ }
+
+ return stack;
+ }
+
+ function isElementVisible(element) {
+ return (getCSS(element, 'display') !== "none" && getCSS(element, 'visibility') !== "hidden" && !element.hasAttribute("data-html2canvas-ignore"));
+ }
+
+ function parseElement (element, stack, pseudoElement) {
+ if (isElementVisible(element)) {
+ stack = renderElement(element, stack, pseudoElement, false) || stack;
+ if (!ignoreElementsRegExp.test(element.nodeName)) {
+ parseChildren(element, stack, pseudoElement);
+ }
+ }
+ }
+
+ function parseChildren(element, stack, pseudoElement) {
+ Util.Children(element).forEach(function(node) {
+ if (node.nodeType === node.ELEMENT_NODE) {
+ parseElement(node, stack, pseudoElement);
+ } else if (node.nodeType === node.TEXT_NODE) {
+ renderText(element, node, stack);
+ }
+ });
+ }
+
+ function init() {
+ var background = getCSS(document.documentElement, "backgroundColor"),
+ transparentBackground = (Util.isTransparent(background) && element === document.body),
+ stack = renderElement(element, null, false, transparentBackground);
+ parseChildren(element, stack);
+
+ if (transparentBackground) {
+ background = stack.backgroundColor;
+ }
+
+ body.removeChild(hidePseudoElements);
+ return {
+ backgroundColor: background,
+ stack: stack
+ };
+ }
+
+ return init();
+ };
+
+ function h2czContext(zindex) {
+ return {
+ zindex: zindex,
+ children: []
+ };
+ }
+
+ _html2canvas.Preload = function( options ) {
+
+ var images = {
+ numLoaded: 0, // also failed are counted here
+ numFailed: 0,
+ numTotal: 0,
+ cleanupDone: false
+ },
+ pageOrigin,
+ Util = _html2canvas.Util,
+ methods,
+ i,
+ count = 0,
+ element = options.elements[0] || document.body,
+ doc = element.ownerDocument,
+ domImages = element.getElementsByTagName('img'), // Fetch images of the present element only
+ imgLen = domImages.length,
+ link = doc.createElement("a"),
+ supportCORS = (function( img ){
+ return (img.crossOrigin !== undefined);
+ })(new Image()),
+ timeoutTimer;
+
+ link.href = window.location.href;
+ pageOrigin = link.protocol + link.host;
+
+ function isSameOrigin(url){
+ link.href = url;
+ link.href = link.href; // YES, BELIEVE IT OR NOT, that is required for IE9 - http://jsfiddle.net/niklasvh/2e48b/
+ var origin = link.protocol + link.host;
+ return (origin === pageOrigin);
+ }
+
+ function start(){
+ Util.log("html2canvas: start: images: " + images.numLoaded + " / " + images.numTotal + " (failed: " + images.numFailed + ")");
+ if (!images.firstRun && images.numLoaded >= images.numTotal){
+ Util.log("Finished loading images: # " + images.numTotal + " (failed: " + images.numFailed + ")");
+
+ if (typeof options.complete === "function"){
+ options.complete(images);
+ }
+
+ }
+ }
+
+ // TODO modify proxy to serve images with CORS enabled, where available
+ function proxyGetImage(url, img, imageObj){
+ var callback_name,
+ scriptUrl = options.proxy,
+ script;
+
+ link.href = url;
+ url = link.href; // work around for pages with base href="" set - WARNING: this may change the url
+
+ callback_name = 'html2canvas_' + (count++);
+ imageObj.callbackname = callback_name;
+
+ if (scriptUrl.indexOf("?") > -1) {
+ scriptUrl += "&";
+ } else {
+ scriptUrl += "?";
+ }
+ scriptUrl += 'url=' + encodeURIComponent(url) + '&callback=' + callback_name;
+ script = doc.createElement("script");
+
+ window[callback_name] = function(a){
+ if (a.substring(0,6) === "error:"){
+ imageObj.succeeded = false;
+ images.numLoaded++;
+ images.numFailed++;
+ start();
+ } else {
+ setImageLoadHandlers(img, imageObj);
+ img.src = a;
+ }
+ window[callback_name] = undefined; // to work with IE<9 // NOTE: that the undefined callback property-name still exists on the window object (for IE<9)
+ try {
+ delete window[callback_name]; // for all browser that support this
+ } catch(ex) {}
+ script.parentNode.removeChild(script);
+ script = null;
+ delete imageObj.script;
+ delete imageObj.callbackname;
+ };
+
+ script.setAttribute("type", "text/javascript");
+ script.setAttribute("src", scriptUrl);
+ imageObj.script = script;
+ window.document.body.appendChild(script);
+
+ }
+
+ function loadPseudoElement(element, type) {
+ var style = window.getComputedStyle(element, type),
+ content = style.content;
+ if (content.substr(0, 3) === 'url') {
+ methods.loadImage(_html2canvas.Util.parseBackgroundImage(content)[0].args[0]);
+ }
+ loadBackgroundImages(style.backgroundImage, element);
+ }
+
+ function loadPseudoElementImages(element) {
+ loadPseudoElement(element, ":before");
+ loadPseudoElement(element, ":after");
+ }
+
+ function loadGradientImage(backgroundImage, bounds) {
+ var img = _html2canvas.Generate.Gradient(backgroundImage, bounds);
+
+ if (img !== undefined){
+ images[backgroundImage] = {
+ img: img,
+ succeeded: true
+ };
+ images.numTotal++;
+ images.numLoaded++;
+ start();
+ }
+ }
+
+ function invalidBackgrounds(background_image) {
+ return (background_image && background_image.method && background_image.args && background_image.args.length > 0 );
+ }
+
+ function loadBackgroundImages(background_image, el) {
+ var bounds;
+
+ _html2canvas.Util.parseBackgroundImage(background_image).filter(invalidBackgrounds).forEach(function(background_image) {
+ if (background_image.method === 'url') {
+ methods.loadImage(background_image.args[0]);
+ } else if(background_image.method.match(/\-?gradient$/)) {
+ if(bounds === undefined) {
+ bounds = _html2canvas.Util.Bounds(el);
+ }
+ loadGradientImage(background_image.value, bounds);
+ }
+ });
+ }
+
+ function getImages (el) {
+ var elNodeType = false;
+
+ // Firefox fails with permission denied on pages with iframes
+ try {
+ Util.Children(el).forEach(getImages);
+ }
+ catch( e ) {}
+
+ try {
+ elNodeType = el.nodeType;
+ } catch (ex) {
+ elNodeType = false;
+ Util.log("html2canvas: failed to access some element's nodeType - Exception: " + ex.message);
+ }
+
+ if (elNodeType === 1 || elNodeType === undefined) {
+ loadPseudoElementImages(el);
+ try {
+ loadBackgroundImages(Util.getCSS(el, 'backgroundImage'), el);
+ } catch(e) {
+ Util.log("html2canvas: failed to get background-image - Exception: " + e.message);
+ }
+ loadBackgroundImages(el);
+ }
+ }
+
+ function setImageLoadHandlers(img, imageObj) {
+ img.onload = function() {
+ if ( imageObj.timer !== undefined ) {
+ // CORS succeeded
+ window.clearTimeout( imageObj.timer );
+ }
+
+ images.numLoaded++;
+ imageObj.succeeded = true;
+ img.onerror = img.onload = null;
+ start();
+ };
+ img.onerror = function() {
+ if (img.crossOrigin === "anonymous") {
+ // CORS failed
+ window.clearTimeout( imageObj.timer );
+
+ // let's try with proxy instead
+ if ( options.proxy ) {
+ var src = img.src;
+ img = new Image();
+ imageObj.img = img;
+ img.src = src;
+
+ proxyGetImage( img.src, img, imageObj );
+ return;
+ }
+ }
+
+ images.numLoaded++;
+ images.numFailed++;
+ imageObj.succeeded = false;
+ img.onerror = img.onload = null;
+ start();
+ };
+ }
+
+ methods = {
+ loadImage: function( src ) {
+ var img, imageObj;
+ if ( src && images[src] === undefined ) {
+ img = new Image();
+ if ( src.match(/data:image\/.*;base64,/i) ) {
+ img.src = src.replace(/url\(['"]{0,}|['"]{0,}\)$/ig, '');
+ imageObj = images[src] = {
+ img: img
+ };
+ images.numTotal++;
+ setImageLoadHandlers(img, imageObj);
+ } else if ( isSameOrigin( src ) || options.allowTaint === true ) {
+ imageObj = images[src] = {
+ img: img
+ };
+ images.numTotal++;
+ setImageLoadHandlers(img, imageObj);
+ img.src = src;
+ } else if ( supportCORS && !options.allowTaint && options.useCORS ) {
+ // attempt to load with CORS
+
+ img.crossOrigin = "anonymous";
+ imageObj = images[src] = {
+ img: img
+ };
+ images.numTotal++;
+ setImageLoadHandlers(img, imageObj);
+ img.src = src;
+ } else if ( options.proxy ) {
+ imageObj = images[src] = {
+ img: img
+ };
+ images.numTotal++;
+ proxyGetImage( src, img, imageObj );
+ }
+ }
+
+ },
+ cleanupDOM: function(cause) {
+ var img, src;
+ if (!images.cleanupDone) {
+ if (cause && typeof cause === "string") {
+ Util.log("html2canvas: Cleanup because: " + cause);
+ } else {
+ Util.log("html2canvas: Cleanup after timeout: " + options.timeout + " ms.");
+ }
+
+ for (src in images) {
+ if (images.hasOwnProperty(src)) {
+ img = images[src];
+ if (typeof img === "object" && img.callbackname && img.succeeded === undefined) {
+ // cancel proxy image request
+ window[img.callbackname] = undefined; // to work with IE<9 // NOTE: that the undefined callback property-name still exists on the window object (for IE<9)
+ try {
+ delete window[img.callbackname]; // for all browser that support this
+ } catch(ex) {}
+ if (img.script && img.script.parentNode) {
+ img.script.setAttribute("src", "about:blank"); // try to cancel running request
+ img.script.parentNode.removeChild(img.script);
+ }
+ images.numLoaded++;
+ images.numFailed++;
+ Util.log("html2canvas: Cleaned up failed img: '" + src + "' Steps: " + images.numLoaded + " / " + images.numTotal);
+ }
+ }
+ }
+
+ // cancel any pending requests
+ if(window.stop !== undefined) {
+ window.stop();
+ } else if(document.execCommand !== undefined) {
+ document.execCommand("Stop", false);
+ }
+ if (document.close !== undefined) {
+ document.close();
+ }
+ images.cleanupDone = true;
+ if (!(cause && typeof cause === "string")) {
+ start();
+ }
+ }
+ },
+
+ renderingDone: function() {
+ if (timeoutTimer) {
+ window.clearTimeout(timeoutTimer);
+ }
+ }
+ };
+
+ if (options.timeout > 0) {
+ timeoutTimer = window.setTimeout(methods.cleanupDOM, options.timeout);
+ }
+
+ Util.log('html2canvas: Preload starts: finding background-images');
+ images.firstRun = true;
+
+ getImages(element);
+
+ Util.log('html2canvas: Preload: Finding images');
+ // load <img> images
+ for (i = 0; i < imgLen; i+=1){
+ methods.loadImage( domImages[i].getAttribute( "src" ) );
+ }
+
+ images.firstRun = false;
+ Util.log('html2canvas: Preload: Done.');
+ if (images.numTotal === images.numLoaded) {
+ start();
+ }
+
+ return methods;
+ };
+
+ _html2canvas.Renderer = function(parseQueue, options){
+
+ // http://www.w3.org/TR/CSS21/zindex.html
+ function createRenderQueue(parseQueue) {
+ var queue = [],
+ rootContext;
+
+ rootContext = (function buildStackingContext(rootNode) {
+ var rootContext = {};
+ function insert(context, node, specialParent) {
+ var zi = (node.zIndex.zindex === 'auto') ? 0 : Number(node.zIndex.zindex),
+ contextForChildren = context, // the stacking context for children
+ isPositioned = node.zIndex.isPositioned,
+ isFloated = node.zIndex.isFloated,
+ stub = {node: node},
+ childrenDest = specialParent; // where children without z-index should be pushed into
+
+ if (node.zIndex.ownStacking) {
+ // '!' comes before numbers in sorted array
+ contextForChildren = stub.context = { '!': [{node:node, children: []}]};
+ childrenDest = undefined;
+ } else if (isPositioned || isFloated) {
+ childrenDest = stub.children = [];
+ }
+
+ if (zi === 0 && specialParent) {
+ specialParent.push(stub);
+ } else {
+ if (!context[zi]) { context[zi] = []; }
+ context[zi].push(stub);
+ }
+
+ node.zIndex.children.forEach(function(childNode) {
+ insert(contextForChildren, childNode, childrenDest);
+ });
+ }
+ insert(rootContext, rootNode);
+ return rootContext;
+ })(parseQueue);
+
+ function sortZ(context) {
+ Object.keys(context).sort().forEach(function(zi) {
+ var nonPositioned = [],
+ floated = [],
+ positioned = [],
+ list = [];
+
+ // positioned after static
+ context[zi].forEach(function(v) {
+ if (v.node.zIndex.isPositioned || v.node.zIndex.opacity < 1) {
+ // http://www.w3.org/TR/css3-color/#transparency
+ // non-positioned element with opactiy < 1 should be stacked as if it were a positioned element with ‘z-index: 0’ and ‘opacity: 1’.
+ positioned.push(v);
+ } else if (v.node.zIndex.isFloated) {
+ floated.push(v);
+ } else {
+ nonPositioned.push(v);
+ }
+ });
+
+ (function walk(arr) {
+ arr.forEach(function(v) {
+ list.push(v);
+ if (v.children) { walk(v.children); }
+ });
+ })(nonPositioned.concat(floated, positioned));
+
+ list.forEach(function(v) {
+ if (v.context) {
+ sortZ(v.context);
+ } else {
+ queue.push(v.node);
+ }
+ });
+ });
+ }
+
+ sortZ(rootContext);
+
+ return queue;
+ }
+
+ function getRenderer(rendererName) {
+ var renderer;
+
+ if (typeof options.renderer === "string" && _html2canvas.Renderer[rendererName] !== undefined) {
+ renderer = _html2canvas.Renderer[rendererName](options);
+ } else if (typeof rendererName === "function") {
+ renderer = rendererName(options);
+ } else {
+ throw new Error("Unknown renderer");
+ }
+
+ if ( typeof renderer !== "function" ) {
+ throw new Error("Invalid renderer defined");
+ }
+ return renderer;
+ }
+
+ return getRenderer(options.renderer)(parseQueue, options, document, createRenderQueue(parseQueue.stack), _html2canvas);
+ };
+
+ _html2canvas.Util.Support = function (options, doc) {
+
+ function supportSVGRendering() {
+ var img = new Image(),
+ canvas = doc.createElement("canvas"),
+ ctx = (canvas.getContext === undefined) ? false : canvas.getContext("2d");
+ if (ctx === false) {
+ return false;
+ }
+ canvas.width = canvas.height = 10;
+ img.src = [
+ "data:image/svg+xml,",
+ "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'>",
+ "<foreignObject width='10' height='10'>",
+ "<div xmlns='http://www.w3.org/1999/xhtml' style='width:10;height:10;'>",
+ "sup",
+ "</div>",
+ "</foreignObject>",
+ "</svg>"
+ ].join("");
+ try {
+ ctx.drawImage(img, 0, 0);
+ canvas.toDataURL();
+ } catch(e) {
+ return false;
+ }
+ _html2canvas.Util.log('html2canvas: Parse: SVG powered rendering available');
+ return true;
+ }
+
+ // Test whether we can use ranges to measure bounding boxes
+ // Opera doesn't provide valid bounds.height/bottom even though it supports the method.
+
+ function supportRangeBounds() {
+ var r, testElement, rangeBounds, rangeHeight, support = false;
+
+ if (doc.createRange) {
+ r = doc.createRange();
+ if (r.getBoundingClientRect) {
+ testElement = doc.createElement('boundtest');
+ testElement.style.height = "123px";
+ testElement.style.display = "block";
+ doc.body.appendChild(testElement);
+
+ r.selectNode(testElement);
+ rangeBounds = r.getBoundingClientRect();
+ rangeHeight = rangeBounds.height;
+
+ if (rangeHeight === 123) {
+ support = true;
+ }
+ doc.body.removeChild(testElement);
+ }
+ }
+
+ return support;
+ }
+
+ return {
+ rangeBounds: supportRangeBounds(),
+ svgRendering: options.svgRendering && supportSVGRendering()
+ };
+ };
+ window.html2canvas = function(elements, opts) {
+ elements = (elements.length) ? elements : [elements];
+ var queue,
+ canvas,
+ options = {
+ // general
+ logging: false,
+ elements: elements,
+ background: "#fff",
+
+ // preload options
+ proxy: null,
+ timeout: 0, // no timeout
+ useCORS: false, // try to load images as CORS (where available), before falling back to proxy
+ allowTaint: false, // whether to allow images to taint the canvas, won't need proxy if set to true
+
+ // parse options
+ svgRendering: false, // use svg powered rendering where available (FF11+)
+ ignoreElements: "IFRAME|OBJECT|PARAM",
+ useOverflow: true,
+ letterRendering: false,
+ chinese: false,
+
+ // render options
+
+ width: null,
+ height: null,
+ taintTest: true, // do a taint test with all images before applying to canvas
+ renderer: "Canvas"
+ };
+
+ options = _html2canvas.Util.Extend(opts, options);
+
+ _html2canvas.logging = options.logging;
+ options.complete = function( images ) {
+
+ if (typeof options.onpreloaded === "function") {
+ if ( options.onpreloaded( images ) === false ) {
+ return;
+ }
+ }
+ queue = _html2canvas.Parse( images, options );
+
+ if (typeof options.onparsed === "function") {
+ if ( options.onparsed( queue ) === false ) {
+ return;
+ }
+ }
+
+ canvas = _html2canvas.Renderer( queue, options );
+
+ if (typeof options.onrendered === "function") {
+ options.onrendered( canvas );
+ }
+
+
+ };
+
+ // for pages without images, we still want this to be async, i.e. return methods before executing
+ window.setTimeout( function(){
+ _html2canvas.Preload( options );
+ }, 0 );
+
+ return {
+ render: function( queue, opts ) {
+ return _html2canvas.Renderer( queue, _html2canvas.Util.Extend(opts, options) );
+ },
+ parse: function( images, opts ) {
+ return _html2canvas.Parse( images, _html2canvas.Util.Extend(opts, options) );
+ },
+ preload: function( opts ) {
+ return _html2canvas.Preload( _html2canvas.Util.Extend(opts, options) );
+ },
+ log: _html2canvas.Util.log
+ };
+ };
+
+ window.html2canvas.log = _html2canvas.Util.log; // for renderers
+ window.html2canvas.Renderer = {
+ Canvas: undefined // We are assuming this will be used
+ };
+ _html2canvas.Renderer.Canvas = function(options) {
+ options = options || {};
+
+ var doc = document,
+ safeImages = [],
+ testCanvas = document.createElement("canvas"),
+ testctx = testCanvas.getContext("2d"),
+ Util = _html2canvas.Util,
+ canvas = options.canvas || doc.createElement('canvas');
+
+ function createShape(ctx, args) {
+ ctx.beginPath();
+ args.forEach(function(arg) {
+ ctx[arg.name].apply(ctx, arg['arguments']);
+ });
+ ctx.closePath();
+ }
+
+ function safeImage(item) {
+ if (safeImages.indexOf(item['arguments'][0].src ) === -1) {
+ testctx.drawImage(item['arguments'][0], 0, 0);
+ try {
+ testctx.getImageData(0, 0, 1, 1);
+ } catch(e) {
+ testCanvas = doc.createElement("canvas");
+ testctx = testCanvas.getContext("2d");
+ return false;
+ }
+ safeImages.push(item['arguments'][0].src);
+ }
+ return true;
+ }
+
+ function renderItem(ctx, item) {
+ switch(item.type){
+ case "variable":
+ ctx[item.name] = item['arguments'];
+ break;
+ case "function":
+ switch(item.name) {
+ case "createPattern":
+ if (item['arguments'][0].width > 0 && item['arguments'][0].height > 0) {
+ try {
+ ctx.fillStyle = ctx.createPattern(item['arguments'][0], "repeat");
+ }
+ catch(e) {
+ Util.log("html2canvas: Renderer: Error creating pattern", e.message);
+ }
+ }
+ break;
+ case "drawShape":
+ createShape(ctx, item['arguments']);
+ break;
+ case "drawImage":
+ if (item['arguments'][8] > 0 && item['arguments'][7] > 0) {
+ if (!options.taintTest || (options.taintTest && safeImage(item))) {
+ ctx.drawImage.apply( ctx, item['arguments'] );
+ }
+ }
+ break;
+ default:
+ ctx[item.name].apply(ctx, item['arguments']);
+ }
+ break;
+ }
+ }
+
+ return function(parsedData, options, document, queue, _html2canvas) {
+ var ctx = canvas.getContext("2d"),
+ newCanvas,
+ bounds,
+ fstyle,
+ zStack = parsedData.stack;
+
+ canvas.width = canvas.style.width = options.width || zStack.ctx.width;
+ canvas.height = canvas.style.height = options.height || zStack.ctx.height;
+
+ fstyle = ctx.fillStyle;
+ ctx.fillStyle = (Util.isTransparent(zStack.backgroundColor) && options.background !== undefined) ? options.background : parsedData.backgroundColor;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = fstyle;
+
+ queue.forEach(function(storageContext) {
+ // set common settings for canvas
+ ctx.textBaseline = "bottom";
+ ctx.save();
+
+ if (storageContext.transform.matrix) {
+ ctx.translate(storageContext.transform.origin[0], storageContext.transform.origin[1]);
+ ctx.transform.apply(ctx, storageContext.transform.matrix);
+ ctx.translate(-storageContext.transform.origin[0], -storageContext.transform.origin[1]);
+ }
+
+ if (storageContext.clip){
+ ctx.beginPath();
+ ctx.rect(storageContext.clip.left, storageContext.clip.top, storageContext.clip.width, storageContext.clip.height);
+ ctx.clip();
+ }
+
+ if (storageContext.ctx.storage) {
+ storageContext.ctx.storage.forEach(function(item) {
+ renderItem(ctx, item);
+ });
+ }
+
+ ctx.restore();
+ });
+
+ Util.log("html2canvas: Renderer: Canvas renderer done - returning canvas obj");
+
+ if (options.elements.length === 1) {
+ if (typeof options.elements[0] === "object" && options.elements[0].nodeName !== "BODY") {
+ // crop image to the bounds of selected (single) element
+ bounds = _html2canvas.Util.Bounds(options.elements[0]);
+ newCanvas = document.createElement('canvas');
+ newCanvas.width = Math.ceil(bounds.width);
+ newCanvas.height = Math.ceil(bounds.height);
+ ctx = newCanvas.getContext("2d");
+
+ ctx.drawImage(canvas, bounds.left, bounds.top, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height);
+ canvas = null;
+ return newCanvas;
+ }
+ }
+
+ return canvas;
+ };
+ };
+ })(window,document); \ No newline at end of file
diff --git a/addons/point_of_sale/static/lib/sha1.js b/addons/point_of_sale/static/lib/sha1.js
new file mode 100644
index 00000000..0fc324cf
--- /dev/null
+++ b/addons/point_of_sale/static/lib/sha1.js
@@ -0,0 +1,159 @@
+/* from http://www.movable-type.co.uk/scripts/sha1.html */
+/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+/* SHA-1 implementation in JavaScript (c) Chris Veness 2002-2014 / MIT Licence */
+/* */
+/* - see http://csrc.nist.gov/groups/ST/toolkit/secure_hashing.html */
+/* http://csrc.nist.gov/groups/ST/toolkit/examples.html */
+/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+/* jshint node:true *//* global define, escape, unescape */
+'use strict';
+
+
+/**
+ * SHA-1 hash function reference implementation.
+ *
+ * @namespace
+ */
+var Sha1 = {};
+
+
+/**
+ * Generates SHA-1 hash of string.
+ *
+ * @param {string} msg - (Unicode) string to be hashed.
+ * @returns {string} Hash of msg as hex character string.
+ */
+Sha1.hash = function(msg) {
+ // convert string to UTF-8, as SHA only deals with byte-streams
+ msg = msg.utf8Encode();
+
+ // constants [§4.2.1]
+ var K = [ 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6 ];
+
+ // PREPROCESSING
+
+ msg += String.fromCharCode(0x80); // add trailing '1' bit (+ 0's padding) to string [§5.1.1]
+
+ // convert string msg into 512-bit/16-integer blocks arrays of ints [§5.2.1]
+ var l = msg.length/4 + 2; // length (in 32-bit integers) of msg + ‘1’ + appended length
+ var N = Math.ceil(l/16); // number of 16-integer-blocks required to hold 'l' ints
+ var M = new Array(N);
+
+ for (var i=0; i<N; i++) {
+ M[i] = new Array(16);
+ for (var j=0; j<16; j++) { // encode 4 chars per integer, big-endian encoding
+ M[i][j] = (msg.charCodeAt(i*64+j*4)<<24) | (msg.charCodeAt(i*64+j*4+1)<<16) |
+ (msg.charCodeAt(i*64+j*4+2)<<8) | (msg.charCodeAt(i*64+j*4+3));
+ } // note running off the end of msg is ok 'cos bitwise ops on NaN return 0
+ }
+ // add length (in bits) into final pair of 32-bit integers (big-endian) [§5.1.1]
+ // note: most significant word would be (len-1)*8 >>> 32, but since JS converts
+ // bitwise-op args to 32 bits, we need to simulate this by arithmetic operators
+ M[N-1][14] = ((msg.length-1)*8) / Math.pow(2, 32); M[N-1][14] = Math.floor(M[N-1][14]);
+ M[N-1][15] = ((msg.length-1)*8) & 0xffffffff;
+
+ // set initial hash value [§5.3.1]
+ var H0 = 0x67452301;
+ var H1 = 0xefcdab89;
+ var H2 = 0x98badcfe;
+ var H3 = 0x10325476;
+ var H4 = 0xc3d2e1f0;
+
+ // HASH COMPUTATION [§6.1.2]
+
+ var W = new Array(80); var a, b, c, d, e;
+ for (var i=0; i<N; i++) {
+
+ // 1 - prepare message schedule 'W'
+ for (var t=0; t<16; t++) W[t] = M[i][t];
+ for (var t=16; t<80; t++) W[t] = Sha1.ROTL(W[t-3] ^ W[t-8] ^ W[t-14] ^ W[t-16], 1);
+
+ // 2 - initialise five working variables a, b, c, d, e with previous hash value
+ a = H0; b = H1; c = H2; d = H3; e = H4;
+
+ // 3 - main loop
+ for (var t=0; t<80; t++) {
+ var s = Math.floor(t/20); // seq for blocks of 'f' functions and 'K' constants
+ var T = (Sha1.ROTL(a,5) + Sha1.f(s,b,c,d) + e + K[s] + W[t]) & 0xffffffff;
+ e = d;
+ d = c;
+ c = Sha1.ROTL(b, 30);
+ b = a;
+ a = T;
+ }
+
+ // 4 - compute the new intermediate hash value (note 'addition modulo 2^32')
+ H0 = (H0+a) & 0xffffffff;
+ H1 = (H1+b) & 0xffffffff;
+ H2 = (H2+c) & 0xffffffff;
+ H3 = (H3+d) & 0xffffffff;
+ H4 = (H4+e) & 0xffffffff;
+ }
+
+ return Sha1.toHexStr(H0) + Sha1.toHexStr(H1) + Sha1.toHexStr(H2) +
+ Sha1.toHexStr(H3) + Sha1.toHexStr(H4);
+};
+
+
+/**
+ * Function 'f' [§4.1.1].
+ * @private
+ */
+Sha1.f = function(s, x, y, z) {
+ switch (s) {
+ case 0: return (x & y) ^ (~x & z); // Ch()
+ case 1: return x ^ y ^ z; // Parity()
+ case 2: return (x & y) ^ (x & z) ^ (y & z); // Maj()
+ case 3: return x ^ y ^ z; // Parity()
+ }
+};
+
+/**
+ * Rotates left (circular left shift) value x by n positions [§3.2.5].
+ * @private
+ */
+Sha1.ROTL = function(x, n) {
+ return (x<<n) | (x>>>(32-n));
+};
+
+
+/**
+ * Hexadecimal representation of a number.
+ * @private
+ */
+Sha1.toHexStr = function(n) {
+ // note can't use toString(16) as it is implementation-dependant,
+ // and in IE returns signed numbers when used on full words
+ var s="", v;
+ for (var i=7; i>=0; i--) { v = (n>>>(i*4)) & 0xf; s += v.toString(16); }
+ return s;
+};
+
+
+/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+
+
+/** Extend String object with method to encode multi-byte string to utf8
+ * - monsur.hossa.in/2012/07/20/utf-8-in-javascript.html */
+if (typeof String.prototype.utf8Encode == 'undefined') {
+ String.prototype.utf8Encode = function() {
+ return unescape( encodeURIComponent( this ) );
+ };
+}
+
+/** Extend String object with method to decode utf8 string to multi-byte */
+if (typeof String.prototype.utf8Decode == 'undefined') {
+ String.prototype.utf8Decode = function() {
+ try {
+ return decodeURIComponent( escape( this ) );
+ } catch (e) {
+ return this; // invalid UTF-8? return as-is
+ }
+ };
+}
+
+
+/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
+if (typeof module != 'undefined' && module.exports) module.exports = Sha1; // CommonJs export
+if (typeof define == 'function' && define.amd) define([], function() { return Sha1; }); // AMD
diff --git a/addons/point_of_sale/static/lib/waitfont.js b/addons/point_of_sale/static/lib/waitfont.js
new file mode 100644
index 00000000..e9359896
--- /dev/null
+++ b/addons/point_of_sale/static/lib/waitfont.js
@@ -0,0 +1,59 @@
+// http://stackoverflow.com/questions/4383226/using-jquery-to-know-when-font-face-fonts-are-loaded
+(function(){
+ function waitForWebfonts(fonts, callback) {
+ var loadedFonts = 0;
+ for(var i = 0, l = fonts.length; i < l; ++i) {
+ (function(font) {
+ var node = document.createElement('span');
+ // Characters that vary significantly among different fonts
+ node.innerHTML = 'giItT1WQy@!-/#';
+ // Visible - so we can measure it - but not on the screen
+ node.style.position = 'absolute';
+ node.style.left = '-10000px';
+ node.style.top = '-10000px';
+ // Large font size makes even subtle changes obvious
+ node.style.fontSize = '300px';
+ // Reset any font properties
+ node.style.fontFamily = 'sans-serif';
+ node.style.fontVariant = 'normal';
+ node.style.fontStyle = 'normal';
+ node.style.fontWeight = 'normal';
+ node.style.letterSpacing = '0';
+ document.body.appendChild(node);
+
+ // Remember width with no applied web font
+ var width = node.offsetWidth;
+
+ node.style.fontFamily = font;
+
+ var interval;
+ function checkFont() {
+ // Compare current width with original width
+ if(node && node.offsetWidth != width) {
+ ++loadedFonts;
+ node.parentNode.removeChild(node);
+ node = null;
+ }
+
+ // If all fonts have been loaded
+ if(loadedFonts >= fonts.length) {
+ if(interval) {
+ clearInterval(interval);
+ }
+ if(loadedFonts == fonts.length) {
+ callback();
+ return true;
+ }
+ }
+ };
+
+ if(!checkFont()) {
+ interval = setInterval(checkFont, 50);
+ }
+ })(fonts[i]);
+ }
+ }
+ window.waitForWebfonts = waitForWebfonts;
+})();
+
+
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>
diff --git a/addons/point_of_sale/static/tests/tours/Chrome.tour.js b/addons/point_of_sale/static/tests/tours/Chrome.tour.js
new file mode 100644
index 00000000..a1c992de
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/Chrome.tour.js
@@ -0,0 +1,103 @@
+odoo.define('point_of_sale.tour.Chrome', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ // Order 1 is at Product Screen
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '2', '2.0');
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0001', 'Ongoing');
+
+ // Order 2 is at Payment Screen
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Monitor Stand', '3', '4', '12.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0002', 'Payment');
+
+ // Order 3 is at Receipt Screen
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6', '30.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0003', 'Receipt');
+
+ // Select order 1, should be at Product Screen
+ TicketScreen.do.selectOrder('-0001');
+ ProductScreen.check.productIsDisplayed('Desk Pad');
+ ProductScreen.check.selectedOrderlineHas('Desk Pad', '1.0', '2.0');
+
+ // Select order 2, should be at Payment Screen
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.selectOrder('-0002');
+ PaymentScreen.check.emptyPaymentlines('12.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+
+ // Select order 3, should be at Receipt Screen
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.selectOrder('-0003');
+ ReceiptScreen.check.totalAmountContains('30.0');
+
+ // Pay order 1, with change
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.selectOrder('-0001');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('2 0');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.totalAmountContains('2.0');
+
+ // Order 1 now should have Receipt status
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0001', 'Receipt');
+
+ // Select order 3, should still be at Receipt Screen
+ // and the total amount doesn't change.
+ TicketScreen.do.selectOrder('-0003');
+ ReceiptScreen.check.totalAmountContains('30.0');
+
+ // click next screen on order 3
+ // then delete the new empty order
+ ReceiptScreen.do.clickNextOrder();
+ ProductScreen.check.orderIsEmpty();
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.deleteOrder('-0004');
+ TicketScreen.do.deleteOrder('-0001');
+
+ // After deleting order 1 above, order 2 became
+ // the 2nd-row order and it has payment status
+ TicketScreen.check.nthRowContains(2, 'Payment')
+ TicketScreen.do.deleteOrder('-0002');
+ Chrome.do.confirmPopup();
+ TicketScreen.do.clickNewTicket();
+
+ // Invoice an order
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6');
+ ProductScreen.do.clickCustomerButton();
+ ProductScreen.do.clickCustomer('Nicole Ford');
+ ProductScreen.do.clickSetCustomer();
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.clickInvoiceButton();
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+
+ Tour.register('ChromeTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js
new file mode 100644
index 00000000..cfd6483a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js
@@ -0,0 +1,138 @@
+odoo.define('point_of_sale.tour.OrderManagementScreen', function (require) {
+ 'use strict';
+
+ const { OrderManagementScreen } = require('point_of_sale.tour.OrderManagementScreenTourMethods');
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods');
+ const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { makeFullOrder } = require('point_of_sale.tour.CompositeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // make one order and check if it can be seen from the management screen.
+ // order 0001
+ makeFullOrder({ orderlist: [['Whiteboard Pen', '5', '6']], payment: ['Cash', '30'] });
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.isShown();
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0001', total: '30' });
+
+ OrderManagementScreen.do.clickBack();
+
+ // make multiple orders and check them in the management screen.
+ // order 0002
+ makeFullOrder({
+ orderlist: [
+ ['Desk Pad', '1', '2'],
+ ['Monitor Stand', '3', '4'],
+ ['Whiteboard Pen', '5', '6'],
+ ],
+ payment: ['Bank', '44'],
+ });
+ // order 0003
+ makeFullOrder({
+ orderlist: [
+ ['Desk Pad', '1', '2'],
+ ['Whiteboard Pen', '5', '6'],
+ ],
+ customer: 'Colleen Diaz',
+ payment: ['Cash', '50'],
+ });
+ // order 0004
+ makeFullOrder({
+ orderlist: [
+ ['Monitor Stand', '3', '4'],
+ ['Whiteboard Pen', '5', '6'],
+ ],
+ payment: ['Bank', '42'],
+ });
+
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.isShown();
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0002', total: '44' });
+ OrderManagementScreen.check.orderlistHas({
+ orderName: '0003',
+ total: '32',
+ customer: 'Colleen Diaz',
+ });
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0004', total: '42' });
+
+ // click the currently active order
+ OrderManagementScreen.do.clickOrder('-0005');
+ ProductScreen.check.isShown();
+
+ // Add 2 orders, they should appear in order management screen
+ // order 0006
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '66', '6');
+
+ // order 0007, should be at payment screen
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Monitor Stand', '55', '5');
+ ProductScreen.do.clickCustomerButton();
+ ClientListScreen.exec.setClient('Azure Interior');
+ ProductScreen.do.clickPayButton();
+
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0006', total: '396' });
+ OrderManagementScreen.check.orderlistHas({
+ orderName: '-0007',
+ total: '275',
+ customer: 'Azure Interior',
+ });
+
+ // select a paid order, order row should be highlighted and should show order details
+ OrderManagementScreen.do.clickOrder('-0004');
+ OrderManagementScreen.check.highlightedOrderRowHas('-0004');
+ OrderManagementScreen.check.orderDetailsHas({
+ lines: [
+ { product: 'Monitor Stand', quantity: '3' },
+ { product: 'Whiteboard Pen', quantity: '5' },
+ ],
+ total: '42',
+ });
+ OrderManagementScreen.do.clickOrder('-0001');
+ OrderManagementScreen.check.highlightedOrderRowHas('-0001');
+ // 0004 should not be highlighted anymore
+ OrderManagementScreen.check.orderRowIsNotHighlighted('-0004');
+ OrderManagementScreen.check.orderDetailsHas({
+ lines: [{ product: 'Whiteboard Pen', quantity: '5' }],
+ total: '30',
+ });
+
+ // Select a paid order then invoice it. The selected order should remain selected
+ // and will contain a new customer. After invoice, the current customer should be removed.
+ // TODO: enable the following steps once the issue in invoicing is solved.
+ // OrderManagementScreen.do.clickInvoiceButton();
+ // Chrome.do.confirmPopup();
+ // ClientListScreen.check.isShown();
+ // ClientListScreen.exec.setClient('Jesse Brown');
+ // OrderManagementScreen.check.highlightedOrderRowHas('Jesse Brown');
+
+ // Check if order 0007 is selected, it should be at payment screen
+ OrderManagementScreen.do.clickOrder('-0007');
+ PaymentScreen.check.isShown();
+
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.isShown();
+ OrderManagementScreen.do.clickOrder('-0003');
+ OrderManagementScreen.do.clickPrintReceiptButton();
+ OrderManagementScreen.check.reprintReceiptIsShown();
+ OrderManagementScreen.check.receiptChangeIs('18.0');
+ OrderManagementScreen.check.receiptOrderDataContains('-0003');
+ OrderManagementScreen.check.receiptAmountIs('32.0');
+ OrderManagementScreen.do.closeReceipt();
+ OrderManagementScreen.check.isNotHidden();
+
+ Tour.register('OrderManagementScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js
new file mode 100644
index 00000000..296fbd55
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js
@@ -0,0 +1,70 @@
+odoo.define('point_of_sale.tour.PaymentScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ ProductScreen.exec.addOrderline('Letter Tray', '10');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '10.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.emptyPaymentlines('52.8');
+
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('1 1');
+ PaymentScreen.check.selectedPaymentlineHas('Cash', '11.00');
+ PaymentScreen.check.remainingIs('41.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ // remove the selected paymentline with multiple backspace presses
+ PaymentScreen.do.pressNumpad('Backspace Backspace');
+ PaymentScreen.check.selectedPaymentlineHas('Cash', '0.00');
+ PaymentScreen.do.pressNumpad('Backspace');
+ PaymentScreen.check.emptyPaymentlines('52.8');
+
+ // Pay with bank, the selected line should have full amount
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ // remove the line using the delete button
+ PaymentScreen.do.clickPaymentlineDelButton('Bank', '52.8');
+
+ // Use +10 and +50 to increment the amount of the paymentline
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('+10');
+ PaymentScreen.check.remainingIs('42.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.pressNumpad('+50');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('7.2');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickPaymentlineDelButton('Cash', '60.0');
+
+ // Multiple paymentlines
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('1');
+ PaymentScreen.check.remainingIs('51.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('5');
+ PaymentScreen.check.remainingIs('46.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.pressNumpad('2 0');
+ PaymentScreen.check.remainingIs('26.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+
+ Tour.register('PaymentScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js
new file mode 100644
index 00000000..d3acf388
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js
@@ -0,0 +1,66 @@
+odoo.define('point_of_sale.tour.ProductConfigurator', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ProductConfigurator } = require('point_of_sale.tour.ProductConfiguratorTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // Click on Configurable Chair product
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.check.isShown();
+
+ // Cancel configuration, not product should be in order
+ ProductConfigurator.do.cancelAttributes();
+ ProductScreen.check.orderIsEmpty();
+
+ // Click on Configurable Chair product
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.check.isShown();
+
+ // Pick Color
+ ProductConfigurator.do.pickColor('Red');
+
+ // Pick Radio
+ ProductConfigurator.do.pickSelect('Metal');
+
+ // Pick Select
+ ProductConfigurator.do.pickRadio('Other');
+
+ // Fill in custom attribute
+ ProductConfigurator.do.fillCustomAttribute('Custom Fabric');
+
+ // Confirm configuration
+ ProductConfigurator.do.confirmAttributes();
+
+ // Check that the product has been added to the order with correct attributes and price
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '1.0', '11.0');
+
+ // Orderlines with the same attributes should be merged
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.do.pickColor('Red');
+ ProductConfigurator.do.pickSelect('Metal');
+ ProductConfigurator.do.pickRadio('Other');
+ ProductConfigurator.do.fillCustomAttribute('Custom Fabric');
+ ProductConfigurator.do.confirmAttributes();
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '2.0', '22.0');
+
+ // Orderlines with different attributes shouldn't be merged
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.do.pickColor('Blue');
+ ProductConfigurator.do.pickSelect('Metal');
+ ProductConfigurator.do.pickRadio('Leather');
+ ProductConfigurator.do.confirmAttributes();
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Blue, Metal, Leather)', '1.0', '10.0');
+
+ Tour.register('ProductConfiguratorTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js
new file mode 100644
index 00000000..9d3dcc3f
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js
@@ -0,0 +1,105 @@
+odoo.define('point_of_sale.tour.ProductScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // Clicking product multiple times should increment quantity
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.10');
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '2.0', '10.20');
+
+ // Clicking product should add new orderline and select the orderline
+ // If orderline exists, increment the quantity
+ ProductScreen.do.clickDisplayedProduct('Letter Tray');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0', '4.80');
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3.0', '15.30');
+
+ // Check effects of clicking numpad buttons
+ ProductScreen.do.clickOrderline('Letter Tray', '1');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '0.0', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3', '15.30');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '0.0', '0.0');
+ ProductScreen.do.pressNumpad('1');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.1');
+ ProductScreen.do.pressNumpad('2');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '12.0', '61.2');
+ ProductScreen.do.pressNumpad('3');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.0', '627.3');
+ ProductScreen.do.pressNumpad('. 5');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '629.85');
+ ProductScreen.do.pressNumpad('Price');
+ ProductScreen.do.pressNumpad('1');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '123.5');
+ ProductScreen.do.pressNumpad('1 .');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,358.5');
+ ProductScreen.do.pressNumpad('Disc');
+ ProductScreen.do.pressNumpad('5 .');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,290.58');
+ ProductScreen.do.pressNumpad('Qty');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.orderIsEmpty();
+
+ // Check different subcategories
+ ProductScreen.do.clickSubcategory('Desks');
+ ProductScreen.check.productIsDisplayed('Desk Pad');
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickSubcategory('Miscellaneous');
+ ProductScreen.check.productIsDisplayed('Whiteboard Pen');
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickSubcategory('Chairs');
+ ProductScreen.check.productIsDisplayed('Letter Tray');
+ ProductScreen.do.clickHomeCategory();
+
+ // Add multiple orderlines then delete each of them until empty
+ ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
+ ProductScreen.do.clickDisplayedProduct('Wall Shelf Unit');
+ ProductScreen.do.clickDisplayedProduct('Small Shelf');
+ ProductScreen.do.clickDisplayedProduct('Magnetic Board');
+ ProductScreen.do.clickDisplayedProduct('Monitor Stand');
+ ProductScreen.do.clickOrderline('Whiteboard Pen', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.clickOrderline('Wall Shelf Unit', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.clickOrderline('Small Shelf', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Small Shelf', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Small Shelf', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.clickOrderline('Magnetic Board', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Magnetic Board', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Magnetic Board', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.orderIsEmpty();
+
+ Tour.register('ProductScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js
new file mode 100644
index 00000000..2e330a9a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js
@@ -0,0 +1,61 @@
+odoo.define('point_of_sale.tour.ReceiptScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { NumberPopup } = require('point_of_sale.tour.NumberPopupTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ const Tour = require('web_tour.tour');
+
+ startSteps();
+
+ // press close button in receipt screen
+ ProductScreen.exec.addOrderline('Letter Tray', '10', '5');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '10');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ // letter tray has 10% tax (search SRC)
+ ReceiptScreen.check.totalAmountContains('55.0');
+ ReceiptScreen.do.clickNextOrder();
+
+ // send email in receipt screen
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.addOrderline('Desk Pad', '6', '5', '30.0');
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '6', '6', '36.0');
+ ProductScreen.exec.addOrderline('Monitor Stand', '6', '1', '6.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('7 0');
+ PaymentScreen.check.remainingIs('2.0');
+ PaymentScreen.do.pressNumpad('0');
+ PaymentScreen.check.remainingIs('0.00');
+ PaymentScreen.check.changeIs('628.0');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ ReceiptScreen.check.totalAmountContains('72.0');
+ ReceiptScreen.do.setEmail('test@receiptscreen.com');
+ ReceiptScreen.do.clickSend();
+ ReceiptScreen.check.emailIsSuccessful();
+ ReceiptScreen.do.clickNextOrder();
+
+ // order with tip
+ // check if tip amount is displayed
+ ProductScreen.exec.addOrderline('Desk Pad', '6', '5');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickTipButton();
+ NumberPopup.do.pressNumpad('1');
+ NumberPopup.check.inputShownIs('1');
+ NumberPopup.do.clickConfirm();
+ PaymentScreen.check.emptyPaymentlines('31.0');
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ ReceiptScreen.check.totalAmountContains('$ 30.00 + $ 1.00 tip');
+ ReceiptScreen.do.clickNextOrder();
+
+ Tour.register('ReceiptScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js
new file mode 100644
index 00000000..a26c0b36
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js
@@ -0,0 +1,54 @@
+odoo.define('point_of_sale.tour.TicketScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '2');
+ ProductScreen.do.clickCustomerButton();
+ ProductScreen.do.clickCustomer('Nicole Ford');
+ ProductScreen.do.clickSetCustomer();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.nthRowContains(2, 'Nicole Ford');
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '3');
+ ProductScreen.do.clickCustomerButton();
+ ProductScreen.do.clickCustomer('Brandon Freeman');
+ ProductScreen.do.clickSetCustomer();
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.nthRowContains(3, 'Brandon Freeman');
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '4');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.nthRowContains(4, 'Receipt');
+ TicketScreen.do.selectFilter('Receipt');
+ TicketScreen.check.nthRowContains(2, 'Receipt');
+ TicketScreen.do.selectFilter('Payment');
+ TicketScreen.check.nthRowContains(2, 'Payment');
+ TicketScreen.do.selectFilter('Ongoing');
+ TicketScreen.check.nthRowContains(2, 'Ongoing');
+ TicketScreen.do.selectFilter('All');
+ TicketScreen.check.nthRowContains(4, 'Receipt');
+ TicketScreen.do.search('Customer', 'Nicole');
+ TicketScreen.check.nthRowContains(2, 'Nicole');
+ TicketScreen.do.search('Customer', 'Brandon');
+ TicketScreen.check.nthRowContains(2, 'Brandon');
+ TicketScreen.do.search('Receipt Number', '-0003');
+ TicketScreen.check.nthRowContains(2, 'Receipt');
+
+ Tour.register('TicketScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js
new file mode 100644
index 00000000..30609a9f
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ confirmPopup() {
+ return [
+ {
+ content: 'confirm popup',
+ trigger: '.popups .modal-dialog .button.confirm',
+ },
+ ];
+ }
+ clickOrderManagementButton() {
+ return [
+ {
+ content: 'check order management button is shown',
+ trigger: '.pos .pos-rightheader .order-management',
+ run: () => {},
+ },
+ {
+ content: 'click order management button',
+ trigger: '.pos .pos-rightheader .order-management',
+ },
+ ];
+ }
+ clickTicketButton() {
+ return [
+ {
+ trigger: '.pos-topheader .ticket-button',
+ },
+ {
+ trigger: '.subwindow .ticket-screen',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('Chrome', Do);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js
new file mode 100644
index 00000000..d6be643e
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js
@@ -0,0 +1,57 @@
+odoo.define('point_of_sale.tour.ClientListScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickClient(name) {
+ return [
+ {
+ content: `click client '${name}' from client list screen`,
+ trigger: `.clientlist-screen .client-list-contents .client-line td:contains("${name}")`,
+ },
+ {
+ content: `check if client '${name}' is highlighted`,
+ trigger: `.clientlist-screen .client-list-contents .client-line.highlight td:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ clickSet() {
+ return [
+ {
+ content: 'check if set button shown',
+ trigger: '.clientlist-screen .button.next.highlight',
+ run: () => {},
+ },
+ {
+ content: 'click set button',
+ trigger: '.clientlist-screen .button.next.highlight',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'client list screen is shown',
+ trigger: '.pos-content .clientlist-screen',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ setClient(name) {
+ const steps = [];
+ steps.push(...this._do.clickClient(name));
+ steps.push(...this._do.clickSet());
+ return steps;
+ }
+ }
+
+ return createTourMethods('ClientListScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js
new file mode 100644
index 00000000..c361a532
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.tour.CompositeTourMethods', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods');
+
+ function makeFullOrder({ orderlist, customer, payment, ntimes = 1 }) {
+ for (let i = 0; i < ntimes; i++) {
+ ProductScreen.exec.addMultiOrderlines(...orderlist);
+ if (customer) {
+ ProductScreen.do.clickCustomerButton();
+ ClientListScreen.exec.setClient(customer);
+ }
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.exec.pay(...payment);
+ ReceiptScreen.exec.nextOrder();
+ }
+ }
+
+ return { makeFullOrder };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js
new file mode 100644
index 00000000..3d8c07cf
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js
@@ -0,0 +1,30 @@
+odoo.define('point_of_sale.tour.ErrorPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickConfirm() {
+ return [
+ {
+ content: 'click confirm button',
+ trigger: '.popup-error .footer .cancel',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'error popup is shown',
+ trigger: '.modal-dialog .popup-error',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('ErrorPopup', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js
new file mode 100644
index 00000000..c12d0d02
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js
@@ -0,0 +1,72 @@
+odoo.define('point_of_sale.tour.NumberPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ /**
+ * Note: Maximum of 2 characters because NumberBuffer only allows 2 consecutive
+ * fast inputs. Fast inputs is the case in tours.
+ *
+ * @param {String} keys space-separated input keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '0 1 2 3 4 5 6 7 8 9 C'.split(' ');
+ const modeButtons = '+1 +10 +2 +20 +5 +50'.split(' ');
+ const decimalSeparators = ', .'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.popup-numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.popup-numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.popup-numpad .numpad-backspace`;
+ } else if (decimalSeparators.includes(key)) {
+ trigger = `.popup-numpad .number-char.dot`;
+ }
+ return {
+ content: `'${key}' pressed in numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+ clickConfirm() {
+ return [
+ {
+ content: 'click confirm button',
+ trigger: '.popup-number .footer .confirm',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'number popup is shown',
+ trigger: '.modal-dialog .popup-number',
+ run: () => {},
+ },
+ ];
+ }
+ inputShownIs(val) {
+ return [
+ {
+ content: 'number input element check',
+ trigger: '.modal-dialog .popup-number .popup-input',
+ run: () => {},
+ },
+ {
+ content: `input shown is '${val}'`,
+ trigger: `.modal-dialog .popup-number .popup-input:contains("${val}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('NumberPopup', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js
new file mode 100644
index 00000000..26e48589
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js
@@ -0,0 +1,180 @@
+odoo.define('point_of_sale.tour.OrderManagementScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickBack() {
+ return [
+ {
+ content: 'order management screen, click back button',
+ trigger: '.order-management-screen .control-panel .button.back',
+ },
+ ];
+ }
+ clickOrder(name, [otherCol, otherColVal] = [null, null]) {
+ let trigger = `.order-management-screen .order-list .order-row .item.name:contains("${name}")`;
+ if (otherCol) {
+ trigger = `${trigger} ~ .item.${otherCol}:contains("${otherColVal}")`;
+ }
+ return [
+ {
+ content: `clicking order '${name}' from orderlist`,
+ trigger,
+ },
+ ];
+ }
+ clickInvoiceButton() {
+ return [
+ {
+ content: 'click invoice button',
+ trigger: '.order-management-screen .control-button span:contains("Invoice")',
+ },
+ ];
+ }
+ clickPrintReceiptButton() {
+ return [
+ {
+ content: 'click reprint receipt button',
+ trigger: '.order-management-screen .control-button span:contains("Print Receipt")'
+ }
+ ]
+ }
+ clickCustomerButton() {
+ return [
+ {
+ content: 'click customer button',
+ trigger: '.order-management-screen .actionpad .button.set-customer',
+ },
+ ];
+ }
+ closeReceipt() {
+ return [
+ {
+ content: 'close receipt',
+ trigger: '.receipt-screen .button.back',
+ }
+ ]
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'order management screen is shown',
+ trigger: '.pos .pos-content .order-management-screen',
+ run: () => {},
+ },
+ ];
+ }
+ orderlistHas({ orderName, total, customer }) {
+ const steps = [];
+ steps.push({
+ content: `order list has row having: name '${orderName}', total '${total}'`,
+ trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${total}")`,
+ run: () => {},
+ });
+ if (customer) {
+ steps.push({
+ content: `order list has row having: name '${orderName}', customer '${customer}'`,
+ trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${customer}")`,
+ run: () => {},
+ });
+ }
+ return steps;
+ }
+ highlightedOrderRowHas(name) {
+ return [
+ {
+ content: `order '${name}' in orderlist is highligted`,
+ trigger: `.order-list .order-row.highlight:has(> .item:contains("${name}"))`,
+ run: () => {},
+ },
+ ];
+ }
+ orderRowIsNotHighlighted(name) {
+ return [
+ {
+ content: `order '${name}' in orderlist is not highligted`,
+ trigger: `.order-list .order-row:not(:has(.highlight)):has(> .item:contains("${name}"))`,
+ run: () => {},
+ },
+ ];
+ }
+ orderDetailsHas({ lines, total }) {
+ const steps = [];
+ for (let { product, quantity } of lines) {
+ steps.push({
+ content: `order details has product '${product}' and quantity '${quantity}'`,
+ trigger: `.orderlines .product-name:contains("${product}") ~ .info strong:contains("${quantity}")`,
+ run: () => {},
+ });
+ }
+ if (total) {
+ steps.push({
+ content: `order details has total amount of ${total}`,
+ trigger: `.order-container .summary .total .value:contains("${total}")`,
+ run: () => {},
+ });
+ }
+ return steps;
+ }
+ customerIs(name) {
+ return [
+ {
+ content: `set customer is '${name}'`,
+ trigger: `.order-management-screen .actionpad .set-customer:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ reprintReceiptIsShown() {
+ return [
+ {
+ content: 'reprint receipt screen is shown',
+ trigger: '.pos .receipt-screen',
+ run: () => {},
+ }
+ ]
+ }
+ receiptChangeIs(amount) {
+ return [
+ {
+ content: `receipt change is ${amount}`,
+ trigger: `.pos-receipt-amount.receipt-change:contains("${amount}")`,
+ run: () => {},
+ }
+ ]
+ }
+ receiptOrderDataContains(orderInfo) {
+ return [
+ {
+ content: `order data contains ${orderInfo}`,
+ trigger: `.pos-receipt-order-data:contains("${orderInfo}")`,
+ run: () => {},
+ }
+ ]
+ }
+ receiptAmountIs(amount) {
+ return [
+ {
+ content: `receipt amount is ${amount}`,
+ trigger: `.pos-receipt-amount:contains("${amount}")`,
+ run: () => {},
+ }
+ ]
+ }
+ isNotHidden() {
+ return [
+ {
+ content: 'order management screen is not hidden',
+ trigger: `.order-management-screen:not(:has(.oe_hidden))`,
+ run: () => {},
+ }
+ ]
+ }
+ }
+
+ return createTourMethods('OrderManagementScreen', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js
new file mode 100644
index 00000000..93a5cef6
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js
@@ -0,0 +1,215 @@
+odoo.define('point_of_sale.tour.PaymentScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickPaymentMethod(name) {
+ return [
+ {
+ content: `click '${name}' payment method`,
+ trigger: `.paymentmethods .button.paymentmethod:contains("${name}")`,
+ },
+ ];
+ }
+
+ /**
+ * Delete the paymentline having the given payment method name and amount.
+ * @param {String} name payment method
+ * @param {String} amount
+ */
+ clickPaymentlineDelButton(name, amount) {
+ return [
+ {
+ content: `delete ${name} paymentline with ${amount} amount`,
+ trigger: `.paymentlines .paymentline .payment-name:contains("${name}") ~ .delete-button`,
+ },
+ ];
+ }
+
+ clickEmailButton() {
+ return [
+ {
+ content: `click email button`,
+ trigger: `.payment-buttons .js_email`,
+ },
+ ];
+ }
+
+ clickTipButton() {
+ return [
+ {
+ trigger: `.payment-buttons .js_tip`,
+ },
+ ];
+ }
+
+ clickInvoiceButton() {
+ return [{ content: 'click invoice button', trigger: '.payment-buttons .js_invoice' }];
+ }
+
+ clickValidate() {
+ return [
+ {
+ content: 'validate payment',
+ trigger: `.payment-screen .button.next.highlight`,
+ },
+ ];
+ }
+
+ /**
+ * Press the numpad in sequence based on the given space-separated keys.
+ * Note: Maximum of 2 characters because NumberBuffer only allows 2 consecutive
+ * fast inputs. Fast inputs is the case in tours.
+ *
+ * @param {String} keys space-separated numpad keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '. +/- 0 1 2 3 4 5 6 7 8 9'.split(' ');
+ const modeButtons = '+10 +20 +50'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.payment-numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.payment-numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.payment-numpad .number-char img[alt="Backspace"]`;
+ }
+ return {
+ content: `'${key}' pressed in payment numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+
+ clickBack() {
+ return [
+ {
+ content: 'click back button',
+ trigger: '.payment-screen .button.back',
+ },
+ ];
+ }
+
+ clickTipButton() {
+ return [
+ {
+ trigger: '.payment-screen .button.js_tip',
+ },
+ ]
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'payment screen is shown',
+ trigger: '.pos .payment-screen',
+ run: () => {},
+ },
+ ];
+ }
+ /**
+ * Check if change is the provided amount.
+ * @param {String} amount
+ */
+ changeIs(amount) {
+ return [
+ {
+ content: `change is ${amount}`,
+ trigger: `.payment-status-change .amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the remaining is the provided amount.
+ * @param {String} amount
+ */
+ remainingIs(amount) {
+ return [
+ {
+ content: `remaining amount is ${amount}`,
+ trigger: `.payment-status-remaining .amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if validate button is highlighted.
+ * @param {Boolean} isHighlighted
+ */
+ validateButtonIsHighlighted(isHighlighted = true) {
+ return [
+ {
+ content: `validate button is ${
+ isHighlighted ? 'highlighted' : 'not highligted'
+ }`,
+ trigger: isHighlighted
+ ? `.payment-screen .button.next.highlight`
+ : `.payment-screen .button.next:not(:has(.highlight))`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the paymentlines are empty. Also provide the amount to pay.
+ * @param {String} amountToPay
+ */
+ emptyPaymentlines(amountToPay) {
+ return [
+ {
+ content: `there are no paymentlines`,
+ trigger: `.paymentlines-empty`,
+ run: () => {},
+ },
+ {
+ content: `amount to pay is '${amountToPay}'`,
+ trigger: `.paymentlines-empty .total:contains("${amountToPay}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the selected paymentline has the given payment method and amount.
+ * @param {String} paymentMethodName
+ * @param {String} amount
+ */
+ selectedPaymentlineHas(paymentMethodName, amount) {
+ return [
+ {
+ content: `line paid via '${paymentMethodName}' is selected`,
+ trigger: `.paymentlines .paymentline.selected .payment-name:contains("${paymentMethodName}")`,
+ run: () => {},
+ },
+ {
+ content: `amount tendered in the line is '${amount}'`,
+ trigger: `.paymentlines .paymentline.selected .payment-amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ pay(method, amount) {
+ const steps = [];
+ steps.push(...this._do.clickPaymentMethod(method));
+ for (let char of amount.split('')) {
+ steps.push(...this._do.pressNumpad(char));
+ }
+ steps.push(...this._check.validateButtonIsHighlighted());
+ steps.push(...this._do.clickValidate());
+ return steps;
+ }
+ }
+
+ return createTourMethods('PaymentScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js
new file mode 100644
index 00000000..5d10f9fd
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js
@@ -0,0 +1,77 @@
+odoo.define('point_of_sale.tour.ProductConfiguratorTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ pickRadio(name) {
+ return [
+ {
+ content: `picking radio attribute with name ${name}`,
+ trigger: `.product-configurator-popup .radio_attribute_label:contains('${name}')`,
+ },
+ ];
+ }
+
+ pickSelect(name) {
+ return [
+ {
+ content: `picking select attribute with name ${name}`,
+ trigger: `.product-configurator-popup .configurator_select:has(option:contains('${name}'))`,
+ run: `text ${name}`,
+ },
+ ];
+ }
+
+ pickColor(name) {
+ return [
+ {
+ content: `picking color attribute with name ${name}`,
+ trigger: `.product-configurator-popup .configurator_color[data-color='${name}']`,
+ },
+ ];
+ }
+
+ fillCustomAttribute(value) {
+ return [
+ {
+ content: `filling custom attribute with value ${value}`,
+ trigger: `.product-configurator-popup .custom_value`,
+ run: `text ${value}`,
+ },
+ ];
+ }
+
+ confirmAttributes() {
+ return [
+ {
+ content: `confirming product configuration`,
+ trigger: `.product-configurator-popup .button.confirm`,
+ },
+ ];
+ }
+
+ cancelAttributes() {
+ return [
+ {
+ content: `canceling product configuration`,
+ trigger: `.product-configurator-popup .button.cancel`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'product configurator is shown',
+ trigger: '.product-configurator-popup:not(:has(.oe_hidden))',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('ProductConfigurator', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js
new file mode 100644
index 00000000..69aab18b
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js
@@ -0,0 +1,254 @@
+odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickDisplayedProduct(name) {
+ return [
+ {
+ content: `click product '${name}'`,
+ trigger: `.product-list .product-name:contains("${name}")`,
+ },
+ ];
+ }
+
+ clickOrderline(name, quantity) {
+ return [
+ {
+ content: `selecting orderline with product '${name}' and quantity '${quantity}'`,
+ trigger: `.order .orderline:not(:has(.selected)) .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ },
+ {
+ content: `orderline with product '${name}' and quantity '${quantity}' has been selected`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickSubcategory(name) {
+ return [
+ {
+ content: `selecting '${name}' subcategory`,
+ trigger: `.products-widget > .products-widget-control .category-simple-button:contains("${name}")`,
+ },
+ {
+ content: `'${name}' subcategory selected`,
+ trigger: `.breadcrumbs .breadcrumb-button:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickHomeCategory() {
+ return [
+ {
+ content: `click Home subcategory`,
+ trigger: `.breadcrumbs .breadcrumb-home`,
+ },
+ ];
+ }
+
+ /**
+ * Press the numpad in sequence based on the given space-separated keys.
+ * NOTE: Maximum of 2 characters because NumberBuffer only allows 2 consecutive
+ * fast inputs. Fast inputs is the case in tours.
+ *
+ * @param {String} keys space-separated numpad keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '. 0 1 2 3 4 5 6 7 8 9'.split(' ');
+ const modeButtons = 'Qty Price Disc'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.numpad .numpad-backspace`;
+ } else if (key === '+/-') {
+ trigger = `.numpad .numpad-minus`;
+ }
+ return {
+ content: `'${key}' pressed in product screen numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+
+ clickPayButton() {
+ return [
+ { content: 'click pay button', trigger: '.actionpad .button.pay' },
+ {
+ content: 'now in payment screen',
+ trigger: '.pos-content .payment-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ clickCustomerButton() {
+ return [
+ { content: 'click customer button', trigger: '.actionpad .button.set-customer' },
+ {
+ content: 'customer screen is shown',
+ trigger: '.pos-content .clientlist-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ clickCustomer(name) {
+ return [
+ {
+ content: `select customer '${name}'`,
+ trigger: `.clientlist-screen .client-line td:contains("${name}")`,
+ },
+ {
+ content: `client line '${name}' is highlighted`,
+ trigger: `.clientlist-screen .client-line.highlight td:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickSetCustomer() {
+ return [
+ {
+ content: 'click set customer',
+ trigger: '.clientlist-screen .button.next.highlight',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'product screen is shown',
+ trigger: '.product-screen:not(:has(.oe_hidden))',
+ run: () => {},
+ },
+ ];
+ }
+ selectedOrderlineHas(name, quantity, price) {
+ const res = [
+ {
+ // check first if the order widget is there and has orderlines
+ content: 'order widget has orderlines',
+ trigger: '.order .orderlines',
+ run: () => {},
+ },
+ {
+ content: `'${name}' is selected`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}")`,
+ run: function () {}, // it's a check
+ },
+ ];
+ if (quantity) {
+ res.push({
+ content: `selected line has ${quantity} quantity`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ run: function () {}, // it's a check
+ });
+ }
+ if (price) {
+ res.push({
+ content: `selected line has total price of ${price}`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .price:contains("${price}")`,
+ run: function () {}, // it's a check
+ });
+ }
+ return res;
+ }
+ orderIsEmpty() {
+ return [
+ {
+ content: `order is empty`,
+ trigger: `.order .order-empty`,
+ run: () => {},
+ },
+ ];
+ }
+
+ productIsDisplayed(name) {
+ return [
+ {
+ content: `'${name}' should be displayed`,
+ trigger: `.product-list .product-name:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ totalAmountIs(amount) {
+ return [
+ {
+ content: `order total amount is '${amount}'`,
+ trigger: `.order-container .order .summary .value:contains("${amount}")`,
+ run: () => {},
+ }
+ ]
+ }
+ modeIsActive(mode) {
+ return [
+ {
+ content: `'${mode}' is active`,
+ trigger: `.numpad button.selected-mode:contains('${mode}')`,
+ run: function () {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ /**
+ * Create an orderline for the given `productName` and `quantity`.
+ * - If `unitPrice` is provided, price of the product of the created line
+ * is changed to that value.
+ * - If `expectedTotal` is provided, the created orderline (which is the currently
+ * selected orderline) is checked if it contains the correct quantity and total
+ * price.
+ *
+ * @param {string} productName
+ * @param {string} quantity
+ * @param {string} unitPrice
+ * @param {string} expectedTotal
+ */
+ addOrderline(productName, quantity, unitPrice = undefined, expectedTotal = undefined) {
+ const res = this._do.clickDisplayedProduct(productName);
+ if (unitPrice) {
+ res.push(...this._do.pressNumpad('Price'));
+ res.push(...this._check.modeIsActive('Price'));
+ res.push(...this._do.pressNumpad(unitPrice.toString().split('').join(' ')));
+ res.push(...this._do.pressNumpad('Qty'));
+ res.push(...this._check.modeIsActive('Qty'));
+ }
+ for (let char of quantity.toString()) {
+ if ('.0123456789'.includes(char)) {
+ res.push(...this._do.pressNumpad(char));
+ } else if ('-'.includes(char)) {
+ res.push(...this._do.pressNumpad('+/-'));
+ }
+ }
+ if (expectedTotal) {
+ res.push(...this._check.selectedOrderlineHas(productName, quantity, expectedTotal));
+ } else {
+ res.push(...this._check.selectedOrderlineHas(productName, quantity));
+ }
+ return res;
+ }
+ addMultiOrderlines(...list) {
+ const steps = [];
+ for (let [product, qty, price] of list) {
+ steps.push(...this.addOrderline(product, qty, price));
+ }
+ return steps;
+ }
+ }
+
+ return createTourMethods('ProductScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js
new file mode 100644
index 00000000..49c26703
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js
@@ -0,0 +1,79 @@
+odoo.define('point_of_sale.tour.ReceiptScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickNextOrder() {
+ return [
+ {
+ content: 'go to next screen',
+ trigger: '.receipt-screen .button.next.highlight',
+ },
+ ];
+ }
+ setEmail(email) {
+ return [
+ {
+ trigger: '.receipt-screen .input-email input',
+ run: `text ${email}`,
+ },
+ ];
+ }
+ clickSend(isHighlighted = true) {
+ return [
+ {
+ trigger: `.receipt-screen .input-email .send${isHighlighted ? '.highlight' : ''}`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'receipt screen is shown',
+ trigger: '.pos .receipt-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ receiptIsThere() {
+ return [
+ {
+ content: 'there should be the receipt',
+ trigger: '.receipt-screen .pos-receipt',
+ run: () => {},
+ },
+ ];
+ }
+
+ totalAmountContains(value) {
+ return [
+ {
+ trigger: `.receipt-screen .top-content h1:contains("${value}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ emailIsSuccessful() {
+ return [
+ {
+ trigger: `.receipt-screen .notice.successful`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ nextOrder() {
+ return [...this._check.isShown(), ...this._do.clickNextOrder()];
+ }
+ }
+
+ return createTourMethods('ReceiptScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js
new file mode 100644
index 00000000..bbe4fc2d
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.tour.SelectionPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickItem(name) {
+ return [
+ {
+ content: `click selection '${name}'`,
+ trigger: `.selection-item:contains("${name}")`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ hasSelectionItem(name) {
+ return [
+ {
+ content: `selection popup has '${name}'`,
+ trigger: `.selection-item:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ isShown() {
+ return [
+ {
+ content: 'selection popup is shown',
+ trigger: '.modal-dialog .popup-selection',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('SelectionPopup', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js
new file mode 100644
index 00000000..fe8f8127
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js
@@ -0,0 +1,107 @@
+odoo.define('point_of_sale.tour.TicketScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickNewTicket() {
+ return [{ trigger: '.ticket-screen .highlight' }];
+ }
+ clickDiscard() {
+ return [{ trigger: '.ticket-screen button.discard' }];
+ }
+ selectOrder(orderName) {
+ return [
+ {
+ trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}")`,
+ },
+ ];
+ }
+ deleteOrder(orderName) {
+ return [
+ {
+ trigger: `.ticket-screen .orders > .order-row > .col:contains("${orderName}") ~ .col[name="delete"]`,
+ },
+ ];
+ }
+ selectFilter(name) {
+ return [
+ {
+ trigger: `.pos-search-bar .filter`,
+ },
+ {
+ trigger: `.pos-search-bar .filter ul`,
+ run: () => {},
+ },
+ {
+ trigger: `.pos-search-bar .filter ul li:contains("${name}")`,
+ },
+ ];
+ }
+ search(field, searchWord) {
+ return [
+ {
+ trigger: '.pos-search-bar input',
+ run: `text ${searchWord}`,
+ },
+ {
+ /**
+ * Manually trigger keydown event to show the search field list
+ * because the previous step do not trigger keydown event.
+ */
+ trigger: '.pos-search-bar input',
+ run: function () {
+ document
+ .querySelector('.pos-search-bar input')
+ .dispatchEvent(new KeyboardEvent('keydown', { key: '' }));
+ },
+ },
+ {
+ trigger: `.pos-search-bar .search ul li:contains("${field}")`,
+ },
+ ];
+ }
+ settleTips() {
+ return [
+ {
+ trigger: '.ticket-screen .buttons .settle-tips',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ checkStatus(orderName, status) {
+ return [
+ {
+ trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}") ~ .col:nth-child(6):contains(${status})`,
+ run: () => {},
+ },
+ ];
+ }
+ /**
+ * Check if the nth row contains the given string.
+ * Note that 1st row is the header-row.
+ */
+ nthRowContains(n, string) {
+ return [
+ {
+ trigger: `.ticket-screen .orders > .order-row:nth-child(${n}):contains("${string}")`,
+ run: () => {},
+ },
+ ];
+ }
+ noNewTicketButton() {
+ return [
+ {
+ trigger: '.ticket-screen .controls .buttons:nth-child(1):has(.discard)',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {}
+
+ return createTourMethods('TicketScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/utils.js b/addons/point_of_sale/static/tests/tours/helpers/utils.js
new file mode 100644
index 00000000..e8fcc591
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/utils.js
@@ -0,0 +1,153 @@
+odoo.define('point_of_sale.tour.utils', function (require) {
+ 'use strict';
+
+ const config = require('web.config');
+
+ /**
+ * USAGE
+ * -----
+ *
+ * ```
+ * const { startSteps, getSteps, createTourMethods } = require('point_of_sale.utils');
+ * const { Other } = require('point_of_sale.tour.OtherMethods');
+ *
+ * // 1. Define classes Do, Check and Execute having methods that
+ * // each return array of tour steps.
+ * class Do {
+ * click() {
+ * return [{ content: 'click button', trigger: '.button' }];
+ * }
+ * }
+ * class Check {
+ * isHighligted() {
+ * return [{ content: 'button is highlighted', trigger: '.button.highlight', run: () => {} }];
+ * }
+ * }
+ * // Notice that Execute has access to methods defined in Do and Check classes
+ * // Also, we can compose steps from other module.
+ * class Execute {
+ * complexSteps() {
+ * return [...this._do.click(), ...this._check.isHighlighted(), ...Other._exec.complicatedSteps()];
+ * }
+ * }
+ *
+ * // 2. Instantiate these class definitions using `createTourMethods`.
+ * // The returned object gives access to the defined methods above
+ * // thru the do, check and exec properties.
+ * // - do gives access to the methods defined in Do class
+ * // - check gives access to the methods defined in Check class
+ * // - exec gives access to the methods defined in Execute class
+ * const Screen = createTourMethods('Screen', Do, Check, Execute);
+ *
+ * // 3. Call `startSteps` to start empty steps.
+ * startSteps();
+ *
+ * // 4. Call the tour methods to populate the steps created by `startSteps`.
+ * Screen.do.click(); // return of this method call is added to steps created by startSteps
+ * Screen.check.isHighlighted() // same as above
+ * Screen.exec.complexSteps() // same as above
+ *
+ * // 5. Call `getSteps` which returns the generated tour steps.
+ * const steps = getSteps();
+ * ```
+ */
+ let steps = [];
+
+ function startSteps() {
+ // always start by waiting for loading to finish
+ steps = [
+ {
+ content: 'wait for loading to finish',
+ trigger: 'body:not(:has(.loader))',
+ run: function () {},
+ },
+ ];
+ }
+
+ function getSteps() {
+ return steps;
+ }
+
+ // this is the method decorator
+ // when the method is called, the generated steps are added
+ // to steps
+ const methodProxyHandler = {
+ apply(target, thisArg, args) {
+ const res = target.call(thisArg, ...args);
+ if (config.isDebug()) {
+ // This step is added before the real steps.
+ // Very useful when debugging because we know which
+ // method call failed and what were the parameters.
+ const constructor = thisArg.constructor.name.split(' ')[1];
+ const methodName = target.name.split(' ')[1];
+ const argList = args
+ .map((a) => (typeof a === 'string' ? `'${a}'` : `${a}`))
+ .join(', ');
+ steps.push({
+ content: `DOING "${constructor}.${methodName}(${argList})"`,
+ trigger: '.pos',
+ run: () => {},
+ });
+ }
+ steps.push(...res);
+ return res;
+ },
+ };
+
+ // we proxy get of the method to decorate the method call
+ const proxyHandler = {
+ get(target, key) {
+ const method = target[key];
+ if (!method) {
+ throw new Error(`Tour method '${key}' is not available.`);
+ }
+ return new Proxy(method.bind(target), methodProxyHandler);
+ },
+ };
+
+ /**
+ * Creates an object with `do`, `check` and `exec` properties which are instances of
+ * the given `Do`, `Check` and `Execute` classes, respectively. Calling methods
+ * automatically adds the returned steps to the steps created by `startSteps`.
+ *
+ * There are however underscored version (_do, _check, _exec).
+ * Calling methods thru the underscored version does not automatically
+ * add the returned steps to the current steps array. Useful when composing
+ * steps from other methods.
+ *
+ * @param {String} name
+ * @param {Function} Do class containing methods which return array of tour steps
+ * @param {Function} Check similar to Do class but the steps are mainly for checking
+ * @param {Function} Execute class containing methods which return array of tour steps
+ * but has access to methods of Do and Check classes via .do and .check,
+ * respectively. Here, we define methods that return tour steps based
+ * on the combination of steps from Do and Check.
+ */
+ function createTourMethods(name, Do, Check = class {}, Execute = class {}) {
+ Object.defineProperty(Do, 'name', { value: `${name}.do` });
+ Object.defineProperty(Check, 'name', { value: `${name}.check` });
+ Object.defineProperty(Execute, 'name', {
+ value: `${name}.exec`,
+ });
+ const methods = { do: new Do(), check: new Check(), exec: new Execute() };
+ // Allow Execute to have access to methods defined in Do and Check
+ // via do and exec, respectively.
+ methods.exec._do = methods.do;
+ methods.exec._check = methods.check;
+ return {
+ Do,
+ Check,
+ Execute,
+ [name]: {
+ do: new Proxy(methods.do, proxyHandler),
+ check: new Proxy(methods.check, proxyHandler),
+ exec: new Proxy(methods.exec, proxyHandler),
+ _do: methods.do,
+ _check: methods.check,
+ _exec: methods.exec,
+ },
+ };
+ }
+
+ return { startSteps, getSteps, createTourMethods };
+});
diff --git a/addons/point_of_sale/static/tests/tours/point_of_sale.js b/addons/point_of_sale/static/tests/tours/point_of_sale.js
new file mode 100644
index 00000000..25f88d3a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/point_of_sale.js
@@ -0,0 +1,436 @@
+odoo.define('point_of_sale.tour.pricelist', function (require) {
+ "use strict";
+
+ var Tour = require('web_tour.tour');
+ var rpc = require('web.rpc');
+ var utils = require('web.utils');
+ var round_di = utils.round_decimals;
+
+ function assert (condition, message) {
+ if (! condition) {
+ throw message || "Assertion failed";
+ }
+ }
+
+ function _build_pricelist_context (pricelist, quantity, date) {
+ return {
+ pricelist: pricelist.id,
+ quantity: quantity,
+ };
+ }
+
+ function compare_backend_frontend (product, pricelist_name, quantity) {
+ return function () {
+ var pricelist = _.findWhere(posmodel.pricelists, {name: pricelist_name});
+ var frontend_price = product.get_price(pricelist, quantity);
+ // ORM applies digits= on non-stored computed field when
+ // reading. It does not however truncate like it does when
+ // storing the field.
+ frontend_price = round_di(frontend_price, posmodel.dp['Product Price']);
+
+ var context = _build_pricelist_context(pricelist, quantity);
+ return rpc.query({model: 'product.product', method: 'read', args: [[product.id], ['price']], context: context})
+ .then(function (backend_result) {
+ var debug_info = _.extend(context, {
+ product: product.id,
+ product_display_name: product.display_name,
+ pricelist_name: pricelist.name,
+ });
+ var backend_price = backend_result[0].price;
+ assert(frontend_price === backend_price,
+ JSON.stringify(debug_info) + ' DOESN\'T MATCH -> ' + backend_price + ' (backend) != ' + frontend_price + ' (frontend)');
+ return Promise.resolve();
+ });
+ };
+ }
+
+ // The global posmodel is only present when the posmodel is instanciated
+ // So, wait for everythiong to be loaded
+ var steps = [{ // Leave category displayed by default
+ content: 'waiting for loading to finish',
+ extra_trigger: 'body .pos:not(:has(.loader))', // Pos has finished loading
+ trigger: 'body:not(.oe_wait)', // WebClient has finished Loading
+ run: function () {
+ var product_wall_shelf = posmodel.db.search_product_in_category(0, 'Wall Shelf Unit')[0];
+ var product_small_shelf = posmodel.db.search_product_in_category(0, 'Small Shelf')[0];
+ var product_magnetic_board = posmodel.db.search_product_in_category(0, 'Magnetic Board')[0];
+ var product_monitor_stand = posmodel.db.search_product_in_category(0, 'Monitor Stand')[0];
+ var product_desk_pad = posmodel.db.search_product_in_category(0, 'Desk Pad')[0];
+ var product_letter_tray = posmodel.db.search_product_in_category(0, 'Letter Tray')[0];
+ var product_whiteboard = posmodel.db.search_product_in_category(0, 'Whiteboard')[0];
+
+ compare_backend_frontend(product_letter_tray, 'Public Pricelist', 0, undefined)()
+ .then(compare_backend_frontend(product_letter_tray, 'Public Pricelist', 1, undefined))
+ .then(compare_backend_frontend(product_letter_tray, 'Fixed', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Fixed', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Fixed', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Percentage', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Percentage', 1, undefined))
+ .then(compare_backend_frontend(product_magnetic_board, 'Percentage', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_magnetic_board, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_monitor_stand, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_desk_pad, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'min_quantity ordering', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'min_quantity ordering', 2, undefined))
+ .then(compare_backend_frontend(product_letter_tray, 'Category vs no category', 1, undefined))
+ .then(compare_backend_frontend(product_letter_tray, 'Category', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Product template', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Dates', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Pricelist base rounding', 1, undefined))
+ .then(compare_backend_frontend(product_whiteboard, 'Public Pricelist', 1, undefined))
+ .then(function () {
+ $('.pos').addClass('done-testing');
+ });
+ },
+ }];
+
+ steps = steps.concat([{
+ content: "wait for unit tests to finish",
+ trigger: ".pos.done-testing",
+ run: function () {}, // it's a check
+ }, {
+ content: "click category switch",
+ trigger: ".breadcrumb-home",
+ run: 'click',
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "verify default pricelist is set",
+ trigger: ".selection-item.selected:contains('Public Pricelist')",
+ run: function () {}, // it's a check
+ }, {
+ content: "select fixed pricelist",
+ trigger: ".selection-item:contains('Fixed')",
+ }, {
+ content: "open customer list",
+ trigger: "button.set-customer",
+ }, {
+ content: "select Deco Addict",
+ trigger: ".client-line:contains('Deco Addict')",
+ }, {
+ content: "confirm selection",
+ trigger: ".clientlist-screen .next",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "verify pricelist changed",
+ trigger: ".selection-item.selected:contains('Public Pricelist')",
+ run: function () {}, // it's a check
+ }, {
+ content: "cancel pricelist dialog",
+ trigger: ".button.cancel:visible",
+ }, {
+ content: "open customer list",
+ trigger: "button.set-customer",
+ }, {
+ content: "select Lumber Inc",
+ trigger: ".client-line:contains('Lumber Inc')",
+ }, {
+ content: "confirm selection",
+ trigger: ".clientlist-screen .next",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "verify pricelist remained public pricelist ('Not loaded' is not available)",
+ trigger: ".selection-item.selected:contains('Public Pricelist')",
+ run: function () {}, // it's a check
+ }, {
+ content: "cancel pricelist dialog",
+ trigger: ".button.cancel:visible",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "select fixed pricelist",
+ trigger: ".selection-item:contains('min_quantity ordering')",
+ }, {
+ content: "order 1 kg shelf",
+ trigger: ".product:contains('Wall Shelf')",
+ }, {
+ content: "change qty to 2 kg",
+ trigger: ".numpad button.input-button:visible:contains('2')",
+ }, {
+ content: "qty of Wall Shelf line should be 2",
+ trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf')",
+ extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf') ~ .info-list .info em:contains('2.0')",
+ run: function() {},
+ }, {
+ content: "verify that unit price of shelf changed to $1",
+ trigger: ".total > .value:contains('$ 2.00')",
+ run: function() {},
+ }, {
+ content: "order different shelf",
+ trigger: ".product:contains('Small Shelf')",
+ }, {
+ content: "Small Shelf line should be selected with quantity 1",
+ trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')",
+ extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .info-list .info em:contains('1.0')",
+ run: function() {}
+ }, {
+ content: "change to price mode",
+ trigger: ".numpad button:contains('Price')",
+ }, {
+ content: "make sure price mode is activated",
+ trigger: ".numpad button.selected-mode:contains('Price')",
+ run: function() {},
+ }, {
+ content: "manually override the unit price of these shelf to $5",
+ trigger: ".numpad button.input-button:visible:contains('5')",
+ }, {
+ content: "Small Shelf line should be selected with unit price of 5",
+ trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')",
+ extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .price:contains('5.0')",
+ }, {
+ content: "change back to qty mode",
+ trigger: ".numpad button:contains('Qty')",
+ }, {
+ content: "make sure qty mode is activated",
+ trigger: ".numpad button.selected-mode:contains('Qty')",
+ run: function() {},
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "select public pricelist",
+ trigger: ".selection-item:contains('Public Pricelist')",
+ }, {
+ content: "verify that the boni shelf have been recomputed and the shelf have not (their price was manually overridden)",
+ trigger: ".total > .value:contains('$ 8.96')",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "select fixed pricelist",
+ trigger: ".selection-item:contains('min_quantity ordering')",
+ }, {
+ content: "close the Point of Sale frontend",
+ trigger: ".header-button",
+ }, {
+ content: "confirm closing the frontend",
+ trigger: ".header-button",
+ run: function() {}, //it's a check,
+ }]);
+
+ Tour.register('pos_pricelist', { test: true, url: '/pos/ui' }, steps);
+});
+
+odoo.define('point_of_sale.tour.acceptance', function (require) {
+ "use strict";
+
+ var Tour = require("web_tour.tour");
+
+ function add_product_to_order(product_name) {
+ return [{
+ content: 'buy ' + product_name,
+ trigger: '.product-list .product-name:contains("' + product_name + '")',
+ }, {
+ content: 'the ' + product_name + ' have been added to the order',
+ trigger: '.order .product-name:contains("' + product_name + '")',
+ run: function () {},
+ }];
+ }
+
+ function set_fiscal_position_on_order(fp_name) {
+ return [{
+ content: 'set fiscal position',
+ trigger: '.control-button.o_fiscal_position_button',
+ }, {
+ content: 'choose fiscal position ' + fp_name + ' to add to the order',
+ trigger: '.popups .popup .selection .selection-item:contains("' + fp_name + '")',
+ }, {
+ content: 'the fiscal position ' + fp_name + ' has been set to the order',
+ trigger: '.control-button.o_fiscal_position_button:contains("' + fp_name + '")',
+ run: function () {},
+ }];
+ }
+
+ function generate_keypad_steps(amount_str, keypad_selector) {
+ var i, steps = [], current_char;
+ for (i = 0; i < amount_str.length; ++i) {
+ current_char = amount_str[i];
+ steps.push({
+ content: 'press ' + current_char + ' on payment keypad',
+ trigger: keypad_selector + ' .input-button:contains("' + current_char + '"):visible'
+ });
+ }
+ return steps;
+ }
+
+ function press_payment_numpad(val) {
+ return [{
+ content: `press ${val} on payment screen numpad`,
+ trigger: `.payment-numpad .input-button:contains("${val}"):visible`,
+ }]
+ }
+
+ function press_product_numpad(val) {
+ return [{
+ content: `press ${val} on product screen numpad`,
+ trigger: `.numpad .input-button:contains("${val}"):visible`,
+ }]
+ }
+
+ function selected_payment_has(name, val) {
+ return [{
+ content: `selected payment is ${name} and has ${val}`,
+ trigger: `.paymentlines .paymentline.selected .payment-name:contains("${name}")`,
+ extra_trigger: `.paymentlines .paymentline.selected .payment-name:contains("${name}") ~ .payment-amount:contains("${val}")`,
+ run: function () {},
+ }]
+ }
+
+ function selected_orderline_has({ product, price = null, quantity = null }) {
+ const result = [];
+ if (price !== null) {
+ result.push({
+ content: `Selected line has product '${product}' and price '${price}'`,
+ trigger: `.order-container .orderlines .orderline.selected .product-name:contains("${product}") ~ span.price:contains("${price}")`,
+ run: function () {},
+ });
+ }
+ if (quantity !== null) {
+ result.push({
+ content: `Selected line has product '${product}' and quantity '${quantity}'`,
+ trigger: `.order-container .orderlines .orderline.selected .product-name:contains('${product}') ~ .info-list .info em:contains('${quantity}')`,
+ run: function () {},
+ });
+ }
+ return result;
+ }
+
+ function verify_order_total(total_str) {
+ return [{
+ content: 'order total contains ' + total_str,
+ trigger: '.order .total .value:contains("' + total_str + '")',
+ run: function () {}, // it's a check
+ }];
+ }
+
+ function goto_payment_screen_and_select_payment_method() {
+ return [{
+ content: "go to payment screen",
+ trigger: '.button.pay',
+ }, {
+ content: "pay with cash",
+ trigger: '.paymentmethod:contains("Cash")',
+ }];
+ }
+
+ function finish_order() {
+ return [{
+ content: "validate the order",
+ trigger: '.payment-screen .button.next.highlight:visible',
+ }, {
+ content: "verify that the order has been successfully sent to the backend",
+ trigger: ".js_connected:visible",
+ run: function () {},
+ }, {
+ content: "click Next Order",
+ trigger: '.receipt-screen .button.next.highlight:visible',
+ }, {
+ content: "check if we left the receipt screen",
+ trigger: '.pos-content .screen:not(:has(.receipt-screen))',
+ run: function () {},
+ }];
+ }
+
+ var steps = [{
+ content: 'waiting for loading to finish',
+ trigger: 'body:not(:has(.loader))',
+ run: function () {},
+ }, { // Leave category displayed by default
+ content: "click category switch",
+ trigger: ".breadcrumb-home",
+ }];
+
+ steps = steps.concat(add_product_to_order('Desk Organizer'));
+ steps = steps.concat(verify_order_total('5.10'));
+
+ steps = steps.concat(add_product_to_order('Desk Organizer'));
+ steps = steps.concat(verify_order_total('10.20'));
+ steps = steps.concat(goto_payment_screen_and_select_payment_method());
+
+ /* add payment line of only 5.20
+ status:
+ order-total := 10.20
+ total-payment := 11.70
+ expect:
+ remaining := 0.00
+ change := 1.50
+ */
+ steps = steps.concat(press_payment_numpad('5'));
+ steps = steps.concat(selected_payment_has('Cash', '5.0'));
+ steps = steps.concat([{
+ content: "verify remaining",
+ trigger: '.payment-status-remaining .amount:contains("5.20")',
+ run: function () {},
+ }, {
+ content: "verify change",
+ trigger: '.payment-status-change .amount:contains("0.00")',
+ run: function () {},
+ }]);
+
+ /* make additional payment line of 6.50
+ status:
+ order-total := 10.20
+ total-payment := 11.70
+ expect:
+ remaining := 0.00
+ change := 1.50
+ */
+ steps = steps.concat([{
+ content: "pay with cash",
+ trigger: '.paymentmethod:contains("Cash")',
+ }]);
+ steps = steps.concat(selected_payment_has('Cash', '5.2'));
+ steps = steps.concat(press_payment_numpad('6'))
+ steps = steps.concat(selected_payment_has('Cash', '6.0'));
+ steps = steps.concat([{
+ content: "verify remaining",
+ trigger: '.payment-status-remaining .amount:contains("0.00")',
+ run: function () {},
+ }, {
+ content: "verify change",
+ trigger: '.payment-status-change .amount:contains("0.80")',
+ run: function () {},
+ }]);
+
+ steps = steps.concat(finish_order());
+
+ // test opw-672118 orderline subtotal rounding
+ steps = steps.concat(add_product_to_order('Desk Organizer'));
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '1.0'}));
+ steps = steps.concat(press_product_numpad('.'))
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.0', price: '0.0'}));
+ steps = steps.concat(press_product_numpad('9'))
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.9', price: '4.59'}));
+ steps = steps.concat(press_product_numpad('9'))
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.99', price: '5.05'}));
+ steps = steps.concat(goto_payment_screen_and_select_payment_method());
+ steps = steps.concat(selected_payment_has('Cash', '5.05'));
+ steps = steps.concat(finish_order());
+
+ // Test fiscal position one2many map (align with backend)
+ steps = steps.concat(add_product_to_order('Letter Tray'));
+ steps = steps.concat(selected_orderline_has({product: 'Letter Tray', quantity: '1.0'}));
+ steps = steps.concat(verify_order_total('5.28'));
+ steps = steps.concat(set_fiscal_position_on_order('FP-POS-2M'));
+ steps = steps.concat(verify_order_total('5.52'));
+
+ steps = steps.concat([{
+ content: "close the Point of Sale frontend",
+ trigger: ".header-button",
+ }, {
+ content: "confirm closing the frontend",
+ trigger: ".header-button.confirm",
+ run: function() {}, //it's a check,
+ }]);
+
+ Tour.register('pos_basic_order', { test: true, url: '/pos/ui' }, steps);
+
+});
diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_env.js b/addons/point_of_sale/static/tests/unit/helpers/test_env.js
new file mode 100644
index 00000000..c4b0b3ec
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/helpers/test_env.js
@@ -0,0 +1,46 @@
+odoo.define('point_of_sale.test_env', async function (require) {
+ 'use strict';
+
+ /**
+ * Many components in PoS are dependent on the PosModel instance (pos).
+ * Therefore, for unit tests that require pos in the Components' env, we
+ * prepared here a test env maker (makePosTestEnv) based on
+ * makeTestEnvironment of web.
+ */
+
+ const makeTestEnvironment = require('web.test_env');
+ const env = require('web.env');
+ const models = require('point_of_sale.models');
+ const Registries = require('point_of_sale.Registries');
+
+ Registries.Component.add(owl.misc.Portal);
+
+ await env.session.is_bound;
+ const pos = new models.PosModel({
+ rpc: env.services.rpc,
+ session: env.session,
+ do_action: async () => {},
+ setLoadingMessage: () => {},
+ setLoadingProgress: () => {},
+ showLoadingSkip: () => {},
+ });
+ await pos.ready;
+
+ /**
+ * @param {Object} env default env
+ * @param {Function} providedRPC mock rpc
+ * @param {Function} providedDoAction mock do_action
+ */
+ function makePosTestEnv(env = {}, providedRPC = null, providedDoAction = null) {
+ env = Object.assign(env, { pos });
+ let posEnv = makeTestEnvironment(env, providedRPC);
+ // Replace rpc in the PosModel instance after loading
+ // data from the server so that every succeeding rpc calls
+ // made by pos are mocked by the providedRPC.
+ pos.rpc = posEnv.rpc;
+ pos.do_action = providedDoAction;
+ return posEnv;
+ }
+
+ return makePosTestEnv;
+});
diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_main.js b/addons/point_of_sale/static/tests/unit/helpers/test_main.js
new file mode 100644
index 00000000..f42e01cb
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/helpers/test_main.js
@@ -0,0 +1,23 @@
+odoo.define('web.web_client', function (require) {
+ // this module is required by the test
+ const { bus } = require('web.core');
+ const WebClient = require('web.AbstractWebClient');
+
+ // listen to unhandled rejected promises, and when the rejection is not due
+ // to a crash, prevent the browser from displaying an 'unhandledrejection'
+ // error in the console, which would make tests crash on each Promise.reject()
+ // something similar is done by the CrashManagerService, but by default, it
+ // isn't deployed in tests
+ bus.on('crash_manager_unhandledrejection', this, function (ev) {
+ if (!ev.reason || !(ev.reason instanceof Error)) {
+ ev.stopPropagation();
+ ev.stopImmediatePropagation();
+ ev.preventDefault();
+ }
+ });
+
+ owl.config.mode = "dev";
+
+ const webClient = new WebClient();
+ return webClient;
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js
new file mode 100644
index 00000000..a0df97fd
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js
@@ -0,0 +1,89 @@
+odoo.define('point_of_sale.tests.ChromeWidgets', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+
+ QUnit.module('unit tests for Chrome Widgets', {});
+
+ QUnit.test('CashierName', async function (assert) {
+ assert.expect(1);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><CashierName></CashierName></div>
+ `;
+ Parent.env.pos.employee.name = 'Test Employee';
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ assert.strictEqual(parent.el.querySelector('span.username').innerText, 'Test Employee');
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('HeaderButton', async function (assert) {
+ assert.expect(1);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><HeaderButton></HeaderButton></div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const headerButton = parent.el.querySelector('.header-button');
+ await testUtils.dom.click(headerButton);
+ await testUtils.nextTick();
+ assert.ok(headerButton.classList.contains('confirm'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('SyncNotification', async function (assert) {
+ assert.expect(5);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <SyncNotification></SyncNotification>
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+ pos.set('synch', { status: 'connected', pending: false });
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connected'));
+
+ pos.set('synch', { status: 'connecting', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connecting'));
+
+ pos.set('synch', { status: 'disconnected', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_disconnected'));
+
+ pos.set('synch', { status: 'error', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_error'));
+
+ pos.set('synch', { status: 'error', pending: 10 });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.js_msg').innerText.includes('10'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js
new file mode 100644
index 00000000..4b2217cb
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js
@@ -0,0 +1,414 @@
+odoo.define('point_of_sale.tests.ComponentRegistry', function(require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+
+ QUnit.module('unit tests for ComponentRegistry', {
+ before() {},
+ });
+
+ QUnit.test('basic extend', async function(assert) {
+ assert.expect(5);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ let a = new RegA();
+ assert.verifySteps(['A', 'A1']);
+ assert.ok(a instanceof RegA);
+ assert.ok(RegA.name === 'A');
+ });
+
+ QUnit.test('addByExtending', async function(assert) {
+ assert.expect(8);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let A2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A2');
+ }
+ };
+ Registries.Component.extend(A, A2);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ const RegB = Registries.Component.get(B);
+ let b = new RegB();
+ assert.verifySteps(['A', 'A1', 'A2', 'B']);
+ assert.ok(b instanceof RegA);
+ assert.ok(b instanceof RegB);
+ assert.ok(RegB.name === 'B');
+ });
+
+ QUnit.test('extend the one that is added by extending', async function(assert) {
+ assert.expect(6);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let B1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B1');
+ }
+ };
+ Registries.Component.extend(B, B1);
+
+ let B2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B2');
+ }
+ };
+ Registries.Component.extend(B, B2);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ const RegB = Registries.Component.get(B);
+ new RegB();
+ assert.verifySteps(['A', 'A1', 'B', 'B1', 'B2']);
+ });
+
+ QUnit.test('addByExtending based on added by extending', async function(assert) {
+ assert.expect(10);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let C = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('C');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ let B7 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B7');
+ }
+ };
+ Registries.Component.extend(B, B7);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ const RegB = Registries.Component.get(B);
+ const RegC = Registries.Component.get(C);
+ let c = new RegC();
+ assert.verifySteps(['A', 'A1', 'B', 'B7', 'C']);
+ assert.ok(c instanceof RegA);
+ assert.ok(c instanceof RegB);
+ assert.ok(c instanceof RegC);
+ assert.ok(RegC.name === 'C');
+ });
+
+ QUnit.test('deeper inheritance', async function(assert) {
+ assert.expect(9);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let C = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('C');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ let B2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B2');
+ }
+ };
+ Registries.Component.extend(B, B2);
+
+ let B3 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B3');
+ }
+ };
+ Registries.Component.extend(B, B3);
+
+ let A9 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A9');
+ }
+ };
+ Registries.Component.extend(A, A9);
+
+ let E = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('E');
+ }
+ };
+ Registries.Component.addByExtending(E, C);
+
+ Registries.Component.freeze();
+
+ // |A| => A9 -> A1 -> A
+ // |B| => B3 -> B2 -> B -> |A|
+ // |C| => C -> |B|
+ // |E| => E -> |C|
+
+ new (Registries.Component.get(E))();
+ assert.verifySteps(['A', 'A1', 'A9', 'B', 'B2', 'B3', 'C', 'E']);
+ });
+
+ QUnit.test('mixins?', async function(assert) {
+ assert.expect(12);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let Mixin = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('Mixin');
+ }
+ mixinMethod() {
+ return 'mixinMethod';
+ }
+ get mixinGetter() {
+ return 'mixinGetter';
+ }
+ };
+
+ // use the mixin when declaring B.
+ let B = x =>
+ class extends Mixin(x) {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ B = Registries.Component.get(B);
+ const b = new B();
+ assert.verifySteps(['A', 'A1', 'Mixin', 'B']);
+ // instance of B should have the mixin properties
+ assert.strictEqual(b.mixinMethod(), 'mixinMethod');
+ assert.strictEqual(b.mixinGetter, 'mixinGetter');
+
+ // instance of A should not have the mixin properties
+ A = Registries.Component.get(A);
+ const a = new A();
+ assert.verifySteps(['A', 'A1']);
+ assert.notOk(a.mixinMethod);
+ assert.notOk(a.mixinGetter);
+ });
+
+ QUnit.test('extending methods', async function(assert) {
+ assert.expect(16);
+
+ class A {
+ foo() {
+ assert.step('A foo');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ bar() {
+ assert.step('B bar');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ bar() {
+ assert.step('A1 bar');
+ // should only be for A.
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let B1 = x =>
+ class extends x {
+ foo() {
+ super.foo();
+ assert.step('B1 foo');
+ }
+ };
+ Registries.Component.extend(B, B1);
+
+ let C = x =>
+ class extends x {
+ foo() {
+ super.foo();
+ assert.step('C foo');
+ }
+ bar() {
+ super.bar();
+ assert.step('C bar');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ Registries.Component.freeze();
+
+ A = Registries.Component.get(A);
+ B = Registries.Component.get(B);
+ C = Registries.Component.get(C);
+ const a = new A();
+ const b = new B();
+ const c = new C();
+
+ a.foo();
+ assert.verifySteps(['A foo']);
+ b.foo();
+ assert.verifySteps(['A foo', 'B1 foo']);
+ c.foo();
+ assert.verifySteps(['A foo', 'B1 foo', 'C foo']);
+
+ a.bar();
+ assert.verifySteps(['A1 bar']);
+ b.bar();
+ assert.verifySteps(['B bar']);
+ c.bar();
+ assert.verifySteps(['B bar', 'C bar']);
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js b/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js
new file mode 100644
index 00000000..1e9da1e6
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js
@@ -0,0 +1,65 @@
+odoo.define('point_of_sale.tests.NumberBuffer', function(require) {
+ 'use strict';
+
+ const { Component, useState } = owl;
+ const { xml } = owl.tags;
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const makeTestEnvironment = require('web.test_env');
+ const testUtils = require('web.test_utils');
+
+ QUnit.module('unit tests for NumberBuffer', {
+ before() {},
+ });
+
+ QUnit.test('simple fast inputs with capture in between', async function(assert) {
+ assert.expect(3);
+
+ class Root extends Component {
+ constructor() {
+ super();
+ this.state = useState({ buffer: '' });
+ NumberBuffer.activate();
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ state: this.state,
+ });
+ }
+ resetBuffer() {
+ NumberBuffer.capture();
+ NumberBuffer.reset();
+ }
+ }
+ Root.env = makeTestEnvironment();
+ Root.template = xml/* html */ `
+ <div>
+ <p><t t-esc="state.buffer" /></p>
+ <button class="one" t-on-click="trigger('numpad-click-input', { key: '1' })">1</button>
+ <button class="two" t-on-click="trigger('numpad-click-input', { key: '2' })">2</button>
+ <button class="reset" t-on-click="resetBuffer">reset</button>
+ </div>
+ `;
+
+ const root = new Root();
+ await root.mount(testUtils.prepareTarget());
+
+ const oneButton = root.el.querySelector('button.one');
+ const twoButton = root.el.querySelector('button.two');
+ const resetButton = root.el.querySelector('button.reset');
+ const bufferEl = root.el.querySelector('p');
+
+ testUtils.dom.click(oneButton);
+ testUtils.dom.click(twoButton);
+ await testUtils.nextTick();
+ assert.strictEqual(bufferEl.textContent, '12');
+ testUtils.dom.click(resetButton);
+ await testUtils.nextTick();
+ assert.strictEqual(bufferEl.textContent, '');
+ testUtils.dom.click(twoButton);
+ testUtils.dom.click(oneButton);
+ await testUtils.nextTick();
+ assert.strictEqual(bufferEl.textContent, '21');
+
+ root.unmount();
+ root.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js
new file mode 100644
index 00000000..48d3b55d
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js
@@ -0,0 +1,309 @@
+odoo.define('point_of_sale.tests.PaymentScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useListener } = require('web.custom_hooks');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+ const { useState } = owl;
+
+ QUnit.module('unit tests for PaymentScreen components', {});
+
+ QUnit.test('PaymentMethodButton', async function (assert) {
+ assert.expect(2);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('new-payment-line', this._newPaymentLine);
+ }
+ _newPaymentLine() {
+ assert.step('new-payment-line');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentMethodButton paymentMethod="{ name: 'Cash', id: 1 }" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const button = parent.el.querySelector('.paymentmethod');
+ await testUtils.dom.click(button);
+ assert.verifySteps(['new-payment-line']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PSNumpadInputButton', async function (assert) {
+ assert.expect(15);
+
+ class Parent extends PosComponent {
+ constructor({ value, text, changeClassTo }) {
+ super();
+ this.state = useState({ value, text, changeClassTo });
+ useListener('input-from-numpad', this._inputFromNumpad);
+ }
+ _inputFromNumpad({ detail: { key } }) {
+ assert.step(`${key}-input`);
+ }
+ setState(obj) {
+ Object.assign(this.state, obj);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo" />
+ </div>
+ `;
+
+ let parent = new Parent({ value: '1' });
+ await parent.mount(testUtils.prepareTarget());
+
+ let button = parent.el.querySelector('button');
+ assert.ok(button.textContent.includes('1'));
+ assert.ok(button.classList.contains('number-char'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['1-input']);
+
+ parent.setState({ value: '2', text: 'Two' });
+ await testUtils.nextTick();
+ assert.ok(button.textContent.includes('Two'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['2-input']);
+
+ parent.setState({ value: '+12', text: null, changeClassTo: 'not-number-char' });
+ await testUtils.nextTick();
+ assert.ok(button.textContent.includes('+12'));
+ assert.ok(button.classList.contains('not-number-char'));
+ // class number-char should have been replaced
+ assert.notOk(button.classList.contains('number-char'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['+12-input']);
+
+ parent.unmount();
+ parent.destroy();
+
+ // using the slot should ignore value and text props of the component
+ Parent.template = xml/* html */ `
+ <div>
+ <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo">
+ <span>UseSlot</span>
+ </PSNumpadInputButton>
+ </div>
+ `;
+ parent = new Parent({ value: 'slotted', text: 'Text' });
+ await parent.mount(testUtils.prepareTarget());
+
+ button = parent.el.querySelector('button');
+ assert.ok(button.textContent.includes('UseSlot'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['slotted-input']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PaymentScreenPaymentLines', async function (assert) {
+ assert.expect(12);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ useListener('delete-payment-line', this._onDeletePaymentLine);
+ useListener('select-payment-line', this._onSelectPaymentLine);
+ }
+ get paymentLines() {
+ return this.order.get_paymentlines();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ mounted() {
+ this.order.paymentlines.on('change', this.render, this);
+ }
+ willUnmount() {
+ this.order.paymentlines.off('change', null, this);
+ }
+ _onDeletePaymentLine() {
+ assert.step('delete-click');
+ }
+ _onSelectPaymentLine() {
+ assert.step('select-click');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentScreenPaymentLines paymentLines="paymentLines" />
+ </div>
+ `;
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const order = parent.env.pos.get_order();
+ const cashPM = { id: 0, name: 'Cash', is_cash_count: true, use_payment_terminal: false };
+ const bankPM = { id: 0, name: 'Bank', is_cash_count: false, use_payment_terminal: false };
+
+ let paymentline1 = order.add_paymentline(cashPM);
+ await testUtils.nextTick();
+
+ let statusContainer = parent.el.querySelector('.payment-status-container');
+ let linesEl = parent.el.querySelector('.paymentlines');
+ assert.ok(linesEl, 'payment lines are shown');
+ let newLine = linesEl.querySelector('.selected');
+ assert.ok(newLine, 'the new line is automatically selected');
+
+ let paymentline2 = order.add_paymentline(bankPM);
+ await testUtils.nextTick();
+ assert.notOk(
+ linesEl.querySelector('.selected') === newLine,
+ 'the previously added paymentline should not be selected anymore'
+ );
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline:not(.heading)').length === 2,
+ 'there should be two paymentlines'
+ );
+
+ let paymentline3 = order.add_paymentline(cashPM);
+ await testUtils.nextTick();
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline:not(.heading)').length === 3,
+ 'there should be three paymentlines'
+ );
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline.selected').length === 1,
+ 'there should only be one selected paymentline'
+ );
+
+ await testUtils.dom.click(linesEl.querySelector('.paymentline.selected .delete-button'));
+ await testUtils.nextTick();
+ assert.verifySteps(['delete-click', 'select-click']);
+
+ // click the 2nd payment line
+ await testUtils.dom.click(linesEl.querySelectorAll('.paymentline:not(.heading)')[1]);
+ await testUtils.nextTick();
+ assert.verifySteps(['select-click']);
+
+ // remove paymentline3 (the selected)
+ order.remove_paymentline(paymentline3);
+ await testUtils.nextTick();
+ assert.notOk(
+ linesEl.querySelector('.paymentline.selected'),
+ 'no more selected payment line'
+ );
+
+ order.remove_paymentline(paymentline1);
+ order.remove_paymentline(paymentline2);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PaymentScreenElectronicPayment', async function (assert) {
+ assert.expect(17);
+
+ class SimulatedPaymentLine extends Backbone.Model {
+ constructor() {
+ super();
+ this.payment_status = 'pending';
+ this.can_be_reversed = false;
+ }
+ canBeAdjusted() {
+ return false;
+ }
+ setPaymentStatus(status) {
+ this.payment_status = status;
+ this.trigger('change');
+ }
+ toggleCanBeReversed() {
+ this.can_be_reversed = !this.can_be_reversed;
+ this.trigger('change');
+ }
+ }
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ this.line = new SimulatedPaymentLine();
+ useListener('send-payment-request', () => assert.step('send-payment-request'));
+ useListener('send-force-done', () => assert.step('send-force-done'));
+ useListener('send-payment-cancel', () => assert.step('send-payment-cancel'));
+ useListener('send-payment-reverse', () => assert.step('send-payment-reverse'));
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentScreenElectronicPayment line="line" />
+ </div>
+ `;
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ assert.ok(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-request']);
+
+ parent.line.setPaymentStatus('retry');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-request']);
+
+ parent.line.setPaymentStatus('force_done');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_force_done'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-force-done']);
+
+ parent.line.setPaymentStatus('waitingCard');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_cancel'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-cancel']);
+
+ parent.line.setPaymentStatus('waiting');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('waitingCancel');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('reversing');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('done');
+ await testUtils.nextTick();
+ assert.notOk(parent.el.querySelector('.paymentline .send_payment_reversal'));
+
+ parent.line.toggleCanBeReversed();
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline .send_payment_reversal'));
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_reversal'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-reverse']);
+
+ parent.line.setPaymentStatus('reversed');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ProductScreen.js b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js
new file mode 100644
index 00000000..bdd9b732
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js
@@ -0,0 +1,603 @@
+odoo.define('point_of_sale.tests.ProductScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+ const { useState } = owl;
+
+ QUnit.module('unit tests for ProductScreen components', {});
+
+ QUnit.test('ActionpadWidget', async function (assert) {
+ assert.expect(7);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ useListener('click-customer', () => assert.step('click-customer'));
+ useListener('click-pay', () => assert.step('click-pay'));
+ this.state = useState({ client: null });
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ActionpadWidget client="state.client" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const setCustomerButton = parent.el.querySelector('button.set-customer');
+ const payButton = parent.el.querySelector('button.pay');
+
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.innerText.includes('Customer'));
+
+ // change to customer with short name
+ parent.state.client = { name: 'Test' };
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.innerText.includes('Test'));
+
+ // change to customer with long name
+ parent.state.client = { name: 'Change Customer' };
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.classList.contains('decentered'));
+
+ parent.state.client = null;
+
+ // click set-customer button
+ await testUtils.dom.click(setCustomerButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['click-customer']);
+
+ // click pay button
+ await testUtils.dom.click(payButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['click-pay']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('NumpadWidget', async function (assert) {
+ assert.expect(25);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('set-numpad-mode', this.setNumpadMode);
+ useListener('numpad-click-input', this.numpadClickInput);
+ this.state = useState({ mode: 'quantity' });
+ }
+ setNumpadMode({ detail: { mode } }) {
+ this.state.mode = mode;
+ assert.step(mode);
+ }
+ numpadClickInput({ detail: { key } }) {
+ assert.step(key);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><NumpadWidget activeMode="state.mode"></NumpadWidget></div>
+ `;
+
+ const pos = Parent.env.pos;
+ // set this old values back after testing
+ const old_config = pos.config;
+ const old_cashier = pos.get('cashier');
+
+ // set dummy values in pos.config and pos.get('cashier')
+ pos.config = {
+ restrict_price_control: false,
+ manual_discount: true
+ };
+ pos.set('cashier', { role: 'manager' });
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const modeButtons = parent.el.querySelectorAll('.mode-button');
+ let qtyButton, discButton, priceButton;
+ for (let button of modeButtons) {
+ if (button.textContent.includes('Qty')) {
+ qtyButton = button;
+ }
+ if (button.textContent.includes('Disc')) {
+ discButton = button;
+ }
+ if (button.textContent.includes('Price')) {
+ priceButton = button;
+ }
+ }
+
+ // initially, qty button is active
+ assert.ok(qtyButton.classList.contains('selected-mode'));
+ assert.ok(!discButton.classList.contains('selected-mode'));
+ assert.ok(!priceButton.classList.contains('selected-mode'));
+
+ await testUtils.dom.click(discButton);
+ await testUtils.nextTick();
+ assert.ok(!qtyButton.classList.contains('selected-mode'));
+ assert.ok(discButton.classList.contains('selected-mode'));
+ assert.ok(!priceButton.classList.contains('selected-mode'));
+ assert.verifySteps(['discount']);
+
+ await testUtils.dom.click(priceButton);
+ await testUtils.nextTick();
+ assert.ok(!qtyButton.classList.contains('selected-mode'));
+ assert.ok(!discButton.classList.contains('selected-mode'));
+ assert.ok(priceButton.classList.contains('selected-mode'));
+ assert.verifySteps(['price']);
+
+ const numpadOne = [...parent.el.querySelectorAll('.number-char').values()].find((el) =>
+ el.textContent.includes('1')
+ );
+ const numpadMinus = parent.el.querySelector('.numpad-minus');
+ const numpadBackspace = parent.el.querySelector('.numpad-backspace');
+
+ await testUtils.dom.click(numpadOne);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+
+ await testUtils.dom.click(numpadMinus);
+ await testUtils.nextTick();
+ assert.verifySteps(['-']);
+
+ await testUtils.dom.click(numpadBackspace);
+ await testUtils.nextTick();
+ assert.verifySteps(['Backspace']);
+
+ await testUtils.dom.click(priceButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['price']);
+
+ // change to price control restriction and the cashier is not manager
+ pos.config.restrict_price_control = true;
+ pos.set('cashier', { role: 'not manager' });
+ await testUtils.nextTick();
+
+ assert.ok(priceButton.classList.contains('disabled-mode'));
+ assert.ok(qtyButton.classList.contains('selected-mode'));
+ // after the cashier is changed, since it is not a manager,
+ // the 'set-numpad-mode' is triggered, setting the mode to
+ // 'quantity'.
+ assert.verifySteps(['quantity']);
+
+ // reset old config and cashier values to pos
+ pos.config = old_config;
+ pos.set('cashier', old_cashier);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('ProductsWidgetControlPanel', async function (assert) {
+ assert.expect(32);
+
+ // This test incorporates the following components:
+ // CategoryBreadcrumb
+ // CategoryButton
+ // CategorySimpleButton
+ // HomeCategoryBreadcrumb
+
+ // Create dummy category data
+ //
+ // Root
+ // | Test1
+ // | | Test2
+ // | ` Test3
+ // | | Test5
+ // | ` Test6
+ // ` Test4
+
+ const rootCategory = { id: 0, name: 'Root', parent: null };
+ const testCategory1 = { id: 1, name: 'Test1', parent: 0 };
+ const testCategory2 = { id: 2, name: 'Test2', parent: 1 };
+ const testCategory3 = { id: 3, name: 'Test3', parent: 1 };
+ const testCategory4 = { id: 4, name: 'Test4', parent: 0 };
+ const testCategory5 = { id: 5, name: 'Test5', parent: 3 };
+ const testCategory6 = { id: 6, name: 'Test6', parent: 3 };
+ const categories = {
+ 0: rootCategory,
+ 1: testCategory1,
+ 2: testCategory2,
+ 3: testCategory3,
+ 4: testCategory4,
+ 5: testCategory5,
+ 6: testCategory6,
+ };
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ selectedCategoryId: 0 });
+ useListener('switch-category', this.switchCategory);
+ useListener('update-search', this.updateSearch);
+ useListener('clear-search', this.clearSearch);
+ }
+ get breadcrumbs() {
+ if (this.state.selectedCategoryId === 0) return [];
+ let current = categories[this.state.selectedCategoryId];
+ const res = [current];
+ while (current.parent != 0) {
+ const toAdd = categories[current.parent];
+ res.push(toAdd);
+ current = toAdd;
+ }
+ return res.reverse();
+ }
+ get subcategories() {
+ return Object.values(categories).filter(
+ ({ parent }) => parent == this.state.selectedCategoryId
+ );
+ }
+ switchCategory({ detail: id }) {
+ this.state.selectedCategoryId = id;
+ assert.step(`${id}`);
+ }
+ updateSearch(event) {
+ assert.step(event.detail);
+ }
+ clearSearch() {
+ assert.step('cleared');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div class="pos">
+ <div class="search-bar-portal">
+ <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" />
+ </div>
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+ const old_config = pos.config;
+ // set dummy config
+ pos.config = { iface_display_categ_images: false };
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // The following tests the breadcrumbs and subcategory buttons
+
+ // check if HomeCategoryBreadcrumb is rendered
+ assert.ok(
+ parent.el.querySelector('.breadcrumb-home'),
+ 'Home category should always be there'
+ );
+ let subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test1')));
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test4')));
+
+ // click Test1
+ let test1Span = subcategorySpans.find((span) => span.textContent.includes('Test1'));
+ await testUtils.dom.click(test1Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+ assert.ok(
+ [...parent.el.querySelectorAll('.breadcrumb-button')][1].textContent.includes('Test1')
+ );
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test2')));
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test3')));
+
+ // click Test2
+ let test2Span = subcategorySpans.find((span) => span.textContent.includes('Test2'));
+ await testUtils.dom.click(test2Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['2']);
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 0, 'Test2 should not have subcategories');
+
+ // go back to Test1
+ let breadcrumb1 = [...parent.el.querySelectorAll('.breadcrumb-button')].find((el) =>
+ el.textContent.includes('Test1')
+ );
+ await testUtils.dom.click(breadcrumb1);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+
+ // click Test3
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ let test3Span = subcategorySpans.find((span) => span.textContent.includes('Test3'));
+ await testUtils.dom.click(test3Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['3']);
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2);
+
+ // click Test6
+ let test6Span = subcategorySpans.find((span) => span.textContent.includes('Test6'));
+ await testUtils.dom.click(test6Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['6']);
+ let breadcrumbButtons = [...parent.el.querySelectorAll('.breadcrumb-button')];
+ assert.ok(breadcrumbButtons.length === 4);
+
+ // Now check subcategory buttons with images
+ pos.config.iface_display_categ_images = true;
+
+ let breadcrumbHome = parent.el.querySelector('.breadcrumb-home');
+ await testUtils.dom.click(breadcrumbHome);
+ await testUtils.nextTick();
+ assert.verifySteps(['0']);
+ assert.ok(
+ !parent.el.querySelector('.category-list').classList.contains('simple'),
+ 'Category list should not have simple class'
+ );
+ let categoryButtons = [...parent.el.querySelectorAll('.category-button')];
+ assert.ok(categoryButtons.length === 2, 'There should be 2 subcategories for Root');
+
+ // The following tests the search bar
+
+ const wait = (ms) => {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+ };
+
+ const inputEl = parent.el.querySelector('.search-box input');
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'A' });
+ // Triggering keyup event doesn't type the key to the input
+ // so we manually assign the value of the input.
+ inputEl.value = 'A';
+ await wait(30);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'B' });
+ inputEl.value = 'AB';
+ await wait(30);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'C' });
+ inputEl.value = 'ABC';
+ await wait(110);
+ // Only after waiting for more than 100ms that update-search is triggered
+ // because the method is debounced.
+ assert.verifySteps(['ABC']);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'D' });
+ inputEl.value = 'ABCD';
+ await wait(110);
+ assert.verifySteps(['ABCD']);
+
+ // clear the search bar
+ await testUtils.dom.click(parent.el.querySelector('.search-box .clear-icon'));
+ await testUtils.nextTick();
+ assert.verifySteps(['cleared']);
+ assert.ok(inputEl.value === '', 'value of the input element should be empty');
+
+ pos.config = old_config;
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('ProductList, ProductItem', async function (assert) {
+ assert.expect(10);
+
+ // patch imageUrl and price of ProductItem component
+ const MockProductItemExt = (X) =>
+ class extends X {
+ get imageUrl() {
+ return 'data:,';
+ }
+ get price() {
+ return this.props.product.price;
+ }
+ };
+
+ const extension = Registries.Component.extend('ProductItem', MockProductItemExt);
+ extension.compile();
+
+ const dummyProducts = [
+ { id: 0, display_name: 'Burger', price: '$10' },
+ { id: 1, display_name: 'Water', price: '$2' },
+ { id: 2, display_name: 'Chair', price: '$25' },
+ ];
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ searchWord: '', products: dummyProducts });
+ useListener('click-product', this._clickProduct);
+ }
+ _clickProduct({ detail: product }) {
+ assert.step(product.display_name);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ProductList products="state.products" searchWord="state.searchWord" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // Check if there are 3 products listed
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 3,
+ 'There should be 3 products listed'
+ );
+
+ // Check contents of product item and click
+ const product1el = parent.el.querySelector(
+ 'article.product[aria-labelledby="article_product_1"]'
+ );
+ assert.ok(product1el.querySelector('.product-img img[alt="Water"]'));
+ assert.ok(product1el.querySelector('.product-img .price-tag').textContent.includes('$2'));
+ await testUtils.dom.click(product1el);
+ await testUtils.nextTick();
+ assert.verifySteps(['Water']);
+
+ // Remove one product, check if only two is listed
+ parent.state.products.splice(0, 1);
+ await testUtils.nextTick();
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 2,
+ 'There should be 2 products listed after removing the first item'
+ );
+
+ // Remove all products, check if empty message is There are no products in this category
+ parent.state.products.splice(0, parent.state.products.length);
+ await testUtils.nextTick();
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 0,
+ 'There should be 0 products listed after removing everything'
+ );
+ assert.ok(
+ parent.el
+ .querySelector('.product-list-empty p')
+ .textContent.includes('There are no products in this category.')
+ );
+
+ // change the searchWord to 'something', check if empty message is No results found
+ parent.state.searchWord = 'something';
+ await testUtils.nextTick();
+ assert.ok(
+ parent.el
+ .querySelector('.product-list-empty p')
+ .textContent.includes('No results found for')
+ );
+ assert.ok(
+ parent.el.querySelector('.product-list-empty p b').textContent.includes('something')
+ );
+
+ extension.remove();
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('Orderline', async function (assert) {
+ assert.expect(10);
+
+ class Parent extends PosComponent {
+ constructor(product) {
+ super();
+ useListener('select-line', this._selectLine);
+ useListener('edit-pack-lot-lines', this._editPackLotLines);
+ this.order.add_product(product);
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ get line() {
+ return this.env.pos.get_order().get_orderlines()[0];
+ }
+ _selectLine() {
+ assert.step('select-line');
+ }
+ _editPackLotLines() {
+ assert.step('edit-pack-lot-lines');
+ }
+ willUnmount() {
+ this.order.remove_orderline(this.line);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <Orderline line="line" />
+ </div>
+ `;
+
+ const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
+ // patch chair2 to have tracking
+ chair2.tracking = 'serial';
+
+ // 1. Test orderline without lot icon
+
+ let parent = new Parent(chair1);
+ await parent.mount(testUtils.prepareTarget());
+
+ let line = parent.el.querySelector('li.orderline');
+ assert.ok(line);
+ assert.notOk(line.querySelector('.line-lot-icon'), 'there should be no lot icon');
+ await testUtils.dom.click(line);
+ assert.verifySteps(['select-line']);
+
+ parent.unmount();
+ parent.destroy();
+
+ // 2. Test orderline with lot icon
+
+ parent = new Parent(chair2);
+ await parent.mount(testUtils.prepareTarget());
+
+ line = parent.el.querySelector('li.orderline');
+ const lotIcon = line.querySelector('.line-lot-icon');
+ assert.ok(line);
+ assert.ok(lotIcon, 'there should be lot icon');
+ await testUtils.dom.click(line);
+ assert.verifySteps(['select-line']);
+ await testUtils.dom.click(lotIcon);
+ assert.verifySteps(['edit-pack-lot-lines']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('OrderWidget', async function (assert) {
+ assert.expect(8);
+
+ // OrderWidget is dependent on its parent's rerendering
+ class Parent extends PosComponent {
+ mounted() {
+ this.env.pos.on('change:selectedOrder', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <OrderWidget />
+ </div>
+ `;
+
+ const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // current order is empty
+ assert.notOk(parent.el.querySelector('.summary'));
+ assert.ok(parent.el.querySelector('.order-empty'));
+
+ // add line to the current order
+ const order1 = parent.env.pos.get_order();
+ order1.add_product(chair1);
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.summary'));
+ assert.notOk(parent.el.querySelector('.order-empty'));
+
+ // selected new order, new order is empty
+ const order2 = parent.env.pos.add_new_order();
+ await testUtils.nextTick();
+ assert.notOk(parent.el.querySelector('.summary'));
+ assert.ok(parent.el.querySelector('.order-empty'));
+
+ // add line to the current order
+ order2.add_product(chair2);
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.summary'));
+ assert.notOk(parent.el.querySelector('.order-empty'));
+
+ parent.env.pos.delete_current_order();
+ parent.env.pos.delete_current_order();
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_popups.js b/addons/point_of_sale/static/tests/unit/test_popups.js
new file mode 100644
index 00000000..205d1b24
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_popups.js
@@ -0,0 +1,180 @@
+odoo.define('point_of_sale.test_popups', function(require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+ const testUtils = require('web.test_utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+
+ QUnit.module('unit tests for Popups', {
+ before() {
+ class Root extends PopupControllerMixin(PosComponent) {
+ static template = xml`
+ <div>
+ <t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />
+ </div>
+ `;
+ }
+ Root.env = makePosTestEnv();
+ this.Root = Root;
+ Registries.Component.freeze();
+ },
+ });
+
+ QUnit.test('ConfirmPopup', async function(assert) {
+ assert.expect(6);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show popup and confirm
+ promResponse = root.showPopup('ConfirmPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+
+ // Step: show popup then cancel
+ promResponse = root.showPopup('ConfirmPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.cancel'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: check texts
+ promResponse = root.showPopup('ConfirmPopup', {
+ title: 'Are you sure?',
+ body: 'Are you having fun?',
+ confirmText: 'Hell Yeah!',
+ cancelText: 'Are you kidding me?',
+ });
+ await testUtils.nextTick();
+ assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
+ assert.strictEqual(root.el.querySelector('.body').innerText.trim(), 'Are you having fun?');
+ assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
+ assert.strictEqual(
+ root.el.querySelector('.cancel').innerText.trim(),
+ 'Are you kidding me?'
+ );
+
+ root.unmount();
+ root.destroy();
+ });
+
+ QUnit.test('NumberPopup', async function(assert) {
+ assert.expect(8);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show NumberPopup and confirm with empty buffer
+ promResponse = root.showPopup('NumberPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(userResponse.payload, "");
+
+ // Step: show NumberPopup and cancel
+ promResponse = root.showPopup('NumberPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(root.el.querySelector('.cancel'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: show NumberPopup and confirm with filled buffer, new title, new text
+ promResponse = root.showPopup('NumberPopup', {
+ title: 'Are you sure?',
+ confirmText: 'Hell Yeah!',
+ cancelText: 'Are you kidding me?',
+ });
+ await testUtils.nextTick();
+ let nodes = Array.from(root.el.querySelectorAll('button'));
+ testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "7"), 'mousedown');
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "+10"), 'mousedown');
+ await testUtils.nextTick();
+ assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
+ assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
+ assert.strictEqual(root.el.querySelector('.cancel').innerText.trim(), 'Are you kidding me?');
+ testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(userResponse.payload, "17");
+
+ root.unmount();
+ root.destroy();
+ });
+
+ QUnit.test('EditListPopup', async function(assert) {
+ assert.expect(7);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show popup and confirm
+ promResponse = root.showPopup('EditListPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([]));
+
+ // Step: show popup and cancel
+ promResponse = root.showPopup('EditListPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.cancel'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: show popup and confirm with a default array
+ let defaultArray = ["Banana", "Cherry"];
+ promResponse = root.showPopup('EditListPopup', {
+ title: "Fruits",
+ isSingleItem: false,
+ array: defaultArray,
+ });
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+
+ assert.strictEqual(userResponse.confirmed, true);
+ let i = 0;
+ defaultArray = defaultArray.map((item) => Object.assign({}, { _id: i++ }, { 'text': item}));
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify(defaultArray));
+
+ // Step: show popup and confirm with a new array
+ promResponse = root.showPopup('EditListPopup', {
+ title: "Fruits",
+ isSingleItem: false,
+ array: ["Banana", "Cherry"],
+ });
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.fa-trash-o'));
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([{ _id: 1, text: "Cherry"}]));
+
+ root.unmount();
+ root.destroy();
+ });
+});