From b2f8170d88239b5eaf04f1e3c0dcef613b78fedc Mon Sep 17 00:00:00 2001 From: Joseph Caburnay Date: Mon, 15 Jun 2020 09:59:37 +0000 Subject: [PATCH] [IMP] point_of_sale,pos_*: allow users to manage orders in the pos frontend We now allow invoicing of finalized orders in an open session in this first iteration of order management in the pos frontend. Additionally, receipt reprinting is also introduced. With this feature, we can now reprint the receipt of old orders. closes odoo/odoo#51141 Task-id: 1981354 Related: odoo/enterprise#11690 Related: odoo/upgrade#1472 Signed-off-by: pimodoo --- addons/point_of_sale/__manifest__.py | 11 + addons/point_of_sale/models/pos_config.py | 11 +- addons/point_of_sale/models/pos_order.py | 67 +++++- addons/point_of_sale/models/pos_payment.py | 16 ++ addons/point_of_sale/static/src/css/pos.css | 179 ++++++++++++++++ addons/point_of_sale/static/src/js/Chrome.js | 34 ++- .../js/ChromeWidgets/OrderManagementButton.js | 36 ++++ .../src/js/ChromeWidgets/OrderSelector.js | 7 + .../static/src/js/ControlButtonsMixin.js | 84 ++++++++ .../src/js/Misc/AbstractReceiptScreen.js | 66 ++++++ .../src/js/Misc/IndependentToOrderScreen.js | 18 ++ .../point_of_sale/static/src/js/PosContext.js | 12 ++ .../ControlButtons/InvoiceButton.js | 155 ++++++++++++++ .../ControlButtons/ReprintReceiptButton.js | 48 +++++ .../MobileOrderManagementScreen.js | 25 +++ .../OrderManagementScreen/OrderDetails.js | 29 +++ .../OrderManagementScreen/OrderFetcher.js | 193 ++++++++++++++++++ .../OrderManagementScreen/OrderList.js | 31 +++ .../OrderManagementControlPanel.js | 124 +++++++++++ .../OrderManagementScreen.js | 102 +++++++++ .../Screens/OrderManagementScreen/OrderRow.js | 42 ++++ .../OrderManagementScreen/OrderlineDetails.js | 55 +++++ .../ReprintReceiptScreen.js | 23 +++ .../Screens/ProductScreen/ActionpadWidget.js | 4 +- .../js/Screens/ProductScreen/ProductScreen.js | 69 +------ .../js/Screens/ReceiptScreen/ReceiptScreen.js | 192 +++++++---------- addons/point_of_sale/static/src/js/models.js | 36 +++- .../point_of_sale/static/src/js/printers.js | 2 +- addons/point_of_sale/static/src/js/utils.js | 32 ++- .../point_of_sale/static/src/xml/Chrome.xml | 1 + .../ChromeWidgets/OrderManagementButton.xml | 12 ++ .../src/xml/ChromeWidgets/OrderSelector.xml | 8 +- .../ControlButtons/InvoiceButton.xml | 12 ++ .../ControlButtons/ReprintReceiptButton.xml | 12 ++ .../MobileOrderManagementScreen.xml | 30 +++ .../OrderManagementScreen/OrderDetails.xml | 35 ++++ .../OrderManagementScreen/OrderList.xml | 20 ++ .../OrderManagementControlPanel.xml | 36 ++++ .../OrderManagementScreen.xml | 32 +++ .../OrderManagementScreen/OrderRow.xml | 15 ++ .../OrderlineDetails.xml | 21 ++ .../ReprintReceiptScreen.xml | 26 +++ .../Screens/ProductScreen/OrderSummary.xml | 2 +- .../Screens/ReceiptScreen/OrderReceipt.xml | 6 +- .../static/tests/tours/Chrome.tour.js | 8 +- .../tests/tours/OrderManagementScreen.tour.js | 135 ++++++++++++ .../static/tests/tours/PaymentScreen.tour.js | 2 +- .../static/tests/tours/ReceiptScreen.tour.js | 8 +- .../tests/tours/helpers/ChromeTourMethods.js | 15 +- .../helpers/ClientListScreenTourMethods.js | 57 ++++++ .../tours/helpers/CompositeTourMethods.js | 23 +++ .../tours/helpers/ErrorPopupTourMethods.js | 2 +- .../tours/helpers/NumberPopupTourMethods.js | 2 +- .../OrderManagementScreenTourMethods.js | 176 ++++++++++++++++ .../tours/helpers/PaymentScreenTourMethods.js | 19 +- .../tours/helpers/ProductScreenTourMethods.js | 25 ++- .../tours/helpers/ReceiptScreenTourMethods.js | 12 +- .../helpers/SelectionPopupTourMethods.js | 2 +- .../static/tests/tours/helpers/utils.js | 23 ++- .../static/tests/unit/test_ChromeWidgets.js | 1 + addons/point_of_sale/tests/test_frontend.py | 5 + .../point_of_sale/views/pos_assets_common.xml | 16 ++ .../point_of_sale/views/pos_assets_index.xml | 4 + .../point_of_sale/views/pos_config_view.xml | 31 ++- .../static/tests/tours/PosHrTourMethods.js | 2 +- .../pos_mercury/static/src/js/pos_mercury.js | 6 + .../static/src/xml/OrderReceipt.xml | 2 +- addons/pos_restaurant/__manifest__.py | 3 +- addons/pos_restaurant/models/pos_order.py | 5 + addons/pos_restaurant/static/src/js/Chrome.js | 5 +- .../src/js/Screens/FloorScreen/FloorScreen.js | 1 + .../OrderManagementScreen.js | 29 +++ .../Screens/OrderManagementScreen/OrderRow.js | 17 ++ addons/pos_restaurant/static/src/js/floors.js | 21 +- .../pos_restaurant/static/src/xml/Chrome.xml | 13 -- .../OrderManagementScreen/OrderList.xml | 12 ++ .../OrderManagementScreen/OrderRow.xml | 12 ++ .../static/tests/tours/ControlButtons.tour.js | 4 +- .../tests/tours/OrderManagementScreen.tour.js | 121 +++++++++++ .../tests/tours/SplitBillScreen.tour.js | 6 +- .../tours/helpers/BillScreenTourMethods.js | 2 +- .../tests/tours/helpers/ChromeTourMethods.js | 5 +- .../tours/helpers/FloorScreenTourMethods.js | 7 +- .../tours/helpers/ProductScreenTourMethods.js | 7 +- .../helpers/SplitBillScreenTourMethods.js | 7 +- .../tours/helpers/TextAreaPopupTourMethods.js | 2 +- .../helpers/TextInputPopupTourMethods.js | 2 +- addons/pos_restaurant/tests/test_frontend.py | 8 + .../views/pos_restaurant_templates.xml | 3 + 89 files changed, 2511 insertions(+), 328 deletions(-) create mode 100644 addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js create mode 100644 addons/point_of_sale/static/src/js/ControlButtonsMixin.js create mode 100644 addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js create mode 100644 addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js create mode 100644 addons/point_of_sale/static/src/js/PosContext.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js create mode 100644 addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml create mode 100644 addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml create mode 100644 addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js create mode 100644 addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js create mode 100644 addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js create mode 100644 addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderRow.js delete mode 100644 addons/pos_restaurant/static/src/xml/Chrome.xml create mode 100644 addons/pos_restaurant/static/src/xml/Screens/OrderManagementScreen/OrderList.xml create mode 100644 addons/pos_restaurant/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml create mode 100644 addons/pos_restaurant/static/tests/tours/OrderManagementScreen.tour.js diff --git a/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/__manifest__.py index 798c8bd49e5..0b8ddfa4148 100644 --- a/addons/point_of_sale/__manifest__.py +++ b/addons/point_of_sale/__manifest__.py @@ -55,6 +55,16 @@ 'static/src/xml/Screens/ClientListScreen/ClientDetails.xml', 'static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml', 'static/src/xml/Screens/ClientListScreen/ClientListScreen.xml', + 'static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml', + 'static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml', + 'static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml', + 'static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml', + 'static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml', + 'static/src/xml/Screens/OrderManagementScreen/OrderList.xml', + 'static/src/xml/Screens/OrderManagementScreen/OrderRow.xml', + 'static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml', + 'static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml', + 'static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml', 'static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml', 'static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml', 'static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml', @@ -83,6 +93,7 @@ 'static/src/xml/ChromeWidgets/CashierName.xml', 'static/src/xml/ChromeWidgets/ProxyStatus.xml', 'static/src/xml/ChromeWidgets/SyncNotification.xml', + 'static/src/xml/ChromeWidgets/OrderManagementButton.xml', 'static/src/xml/ChromeWidgets/HeaderButton.xml', 'static/src/xml/ChromeWidgets/SaleDetailsButton.xml', 'static/src/xml/SaleDetailsReport.xml', diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 2c772499794..0820787f879 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -201,7 +201,7 @@ class PosConfig(models.Model): module_pos_discount = fields.Boolean("Global Discounts") module_pos_loyalty = fields.Boolean("Loyalty Program") module_pos_mercury = fields.Boolean(string="Integrated Card Payments") - module_pos_reprint = fields.Boolean(string="Reprint Receipt") + manage_orders = fields.Boolean(string="Manage Orders") is_posbox = fields.Boolean("PosBox") is_header_or_footer = fields.Boolean("Header & Footer") module_pos_hr = fields.Boolean(help="Show employee login screen") @@ -217,6 +217,7 @@ class PosConfig(models.Model): cash_rounding = fields.Boolean(string="Cash Rounding") only_round_cash_method = fields.Boolean(string="Only apply rounding on cash") has_active_session = fields.Boolean(compute='_compute_current_session') + show_allow_invoicing_alert = fields.Boolean(compute="_compute_show_allow_invoicing_alert") @api.depends('use_pricelist', 'available_pricelist_ids') def _compute_allowed_pricelist_ids(self): @@ -260,6 +261,14 @@ class PosConfig(models.Model): pos_config.current_session_id = session and session[0].id or False pos_config.current_session_state = session and session[0].state or False + @api.depends('module_account', 'manage_orders') + def _compute_show_allow_invoicing_alert(self): + for pos_config in self: + if not pos_config.manage_orders: + pos_config.show_allow_invoicing_alert = False + else: + pos_config.show_allow_invoicing_alert = not pos_config.module_account + @api.depends('session_ids') def _compute_last_session(self): PosSession = self.env['pos.session'] diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index ccb05ff7d42..65961226330 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -177,6 +177,7 @@ class PosOrder(models.Model): 'amount': -pos_order['amount_return'], 'payment_date': fields.Date.context_today(self), 'payment_method_id': cash_payment_method.id, + 'is_change': True, } order.add_payment(return_payment_vals) @@ -253,7 +254,6 @@ class PosOrder(models.Model): to_invoice = fields.Boolean('To invoice') is_invoiced = fields.Boolean('Is Invoiced', compute='_compute_is_invoiced') - @api.depends('account_move') def _compute_is_invoiced(self): for order in self: @@ -572,6 +572,48 @@ class PosOrder(models.Model): orders.sudo().unlink() return orders.ids + @api.model + def search_paid_order_ids(self, config_id, domain, limit, offset): + """Search for 'paid' orders that satisfy the given domain, limit and offset.""" + default_domain = ['&', ('config_id', '=', config_id), '!', '|', ('state', '=', 'draft'), ('state', '=', 'cancelled')] + real_domain = AND([domain, default_domain]) + ids = self.search(AND([domain, default_domain]), limit=limit, offset=offset).ids + totalCount = self.search_count(real_domain) + return {'ids': ids, 'totalCount': totalCount} + + def _export_for_ui(self, order): + timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC') + return { + 'lines': [[0, 0, line] for line in order.lines.export_for_ui()], + 'statement_ids': [[0, 0, payment] for payment in order.payment_ids.export_for_ui()], + 'name': order.pos_reference, + 'uid': order.pos_reference[6:], + 'amount_paid': order.amount_paid, + 'amount_total': order.amount_total, + 'amount_tax': order.amount_tax, + 'amount_return': order.amount_return, + 'pos_session_id': order.session_id.id, + 'is_session_closed': order.session_id.state == 'closed', + 'pricelist_id': order.pricelist_id.id, + 'partner_id': order.partner_id.id, + 'user_id': order.user_id.id, + 'sequence_number': order.sequence_number, + 'creation_date': order.date_order.astimezone(timezone), + 'fiscal_position_id': order.fiscal_position_id.id, + 'to_invoice': order.to_invoice, + 'state': order.state, + 'account_move': order.account_move.id, + 'id': order.id, + } + + def export_for_ui(self): + """ Returns a list of dict with each item having similar signature as the return of + `export_as_JSON` of models.Order. This is useful for back-and-forth communication + between the pos frontend and backend. + """ + return self.mapped(self._export_for_ui) if self else [] + + class PosOrderLine(models.Model): _name = "pos.order.line" _description = "Point of Sale Order Lines" @@ -702,6 +744,22 @@ class PosOrderLine(models.Model): for line in self: line.tax_ids_after_fiscal_position = line.order_id.fiscal_position_id.map_tax(line.tax_ids, line.product_id, line.order_id.partner_id) + def _export_for_ui(self, orderline): + return { + 'qty': orderline.qty, + 'price_unit': orderline.price_unit, + 'price_subtotal': orderline.price_subtotal, + 'price_subtotal_incl': orderline.price_subtotal_incl, + 'product_id': orderline.product_id.id, + 'discount': orderline.discount, + 'tax_ids': [[6, False, orderline.tax_ids.mapped(lambda tax: tax.id)]], + 'id': orderline.id, + 'pack_lot_ids': [[0, 0, lot] for lot in orderline.pack_lot_ids.export_for_ui()], + } + + def export_for_ui(self): + return self.mapped(self._export_for_ui) if self else [] + class PosOrderLineLot(models.Model): _name = "pos.pack.operation.lot" @@ -713,6 +771,13 @@ class PosOrderLineLot(models.Model): lot_name = fields.Char('Lot Name') product_id = fields.Many2one('product.product', related='pos_order_line_id.product_id', readonly=False) + def _export_for_ui(self, lot): + return { + 'lot_name': lot.lot_name, + } + + def export_for_ui(self): + return self.mapped(self._export_for_ui) if self else [] class ReportSaleDetails(models.AbstractModel): diff --git a/addons/point_of_sale/models/pos_payment.py b/addons/point_of_sale/models/pos_payment.py index 8dea1f62b92..f5f21f1c12b 100644 --- a/addons/point_of_sale/models/pos_payment.py +++ b/addons/point_of_sale/models/pos_payment.py @@ -27,6 +27,8 @@ class PosPayment(models.Model): card_type = fields.Char('Type of card used') transaction_id = fields.Char('Payment Transaction ID') payment_status = fields.Char('Payment Status') + ticket = fields.Char('Payment Receipt Info') + is_change = fields.Boolean(string='Is this payment change?', default=False) @api.model def name_get(self): @@ -37,3 +39,17 @@ class PosPayment(models.Model): else: res.append((payment.id, formatLang(self.env, payment.amount, currency_obj=payment.currency_id))) return res + + def _export_for_ui(self, payment): + return { + 'payment_method_id': payment.payment_method_id.id, + 'amount': payment.amount, + 'payment_status': payment.payment_status, + 'card_type': payment.card_type, + 'transaction_id': payment.transaction_id, + 'ticket': payment.ticket, + 'is_change': payment.is_change, + } + + def export_for_ui(self): + return self.mapped(self._export_for_ui) if self else [] diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index b55060fd140..7b393bafcd6 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -2838,3 +2838,182 @@ td { 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; +} diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js index d54b5f22170..7f950cfab71 100644 --- a/addons/point_of_sale/static/src/js/Chrome.js +++ b/addons/point_of_sale/static/src/js/Chrome.js @@ -1,7 +1,7 @@ odoo.define('point_of_sale.Chrome', function(require) { 'use strict'; - const { useState, useRef } = owl.hooks; + const { useState, useRef, useContext } = owl.hooks; const { debounce } = owl.utils; const { loadCSS } = require('web.ajax'); const { useListener } = require('web.custom_hooks'); @@ -11,6 +11,8 @@ odoo.define('point_of_sale.Chrome', function(require) { 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 @@ -34,6 +36,8 @@ odoo.define('point_of_sale.Chrome', function(require) { useListener('set-sync-status', this._onSetSyncStatus); NumberBuffer.activate(); + this.chromeContext = useContext(contexts.chrome); + this.state = useState({ uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING' debugWidgetIsShown: true, @@ -138,10 +142,13 @@ odoo.define('point_of_sale.Chrome', function(require) { if (_.isEmpty(this.env.pos.db.product_by_category_id)) { this._loadDemoData(); } - this.env.pos.push_orders(); // push order in the background, no need to await - // Allow using the app even if not all the images are loaded. - // Basically, preload the images in the background. - setTimeout(() => this._preloadImages()); + 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; @@ -202,14 +209,21 @@ odoo.define('point_of_sale.Chrome', function(require) { __closeTempScreen() { this.tempScreen.isShown = false; } - __showScreen({ detail: { name, props } }) { + __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 = this.constructor.components[name]; - this.mainScreenProps = props || {}; - // 2. Save the screen to the order. + 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. - this._setScreenData(name, props); + if (!(component.prototype instanceof IndependentToOrderScreen)) { + this._setScreenData(name, props); + } } /** * Set the latest screen to the current order. This is done so that 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 00000000000..0bee888052d --- /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/OrderSelector.js b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js index 1eed8ab4010..ad2d8265417 100644 --- a/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js @@ -1,11 +1,18 @@ odoo.define('point_of_sale.OrderSelector', function(require) { 'use strict'; + const { useContext } = owl.hooks; const PosComponent = require('point_of_sale.PosComponent'); const Registries = require('point_of_sale.Registries'); + const contexts = require('point_of_sale.PosContext'); // Previously OrderSelectorWidget class OrderSelector extends PosComponent { + constructor() { + super(...arguments); + // this is used in its template + this.chromeContext = useContext(contexts.chrome); + } mounted() { this.env.pos.get('orders').on('add remove change', () => this.render(), this); this.env.pos.on('change:selectedOrder', () => this.render(), this); 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 00000000000..02b4c3673e6 --- /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/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js new file mode 100644 index 00000000000..6abf4b2e93d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js @@ -0,0 +1,66 @@ +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(); + } + } + /** + * https://stackoverflow.com/questions/21285902/printing-a-part-of-webpage-with-javascript + */ + async _printWeb() { + try { + const isPrinted = document.execCommand('print', false, null); + if (!isPrinted) 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/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js new file mode 100644 index 00000000000..939f0daa43f --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.IndependentToOrderScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + + class IndependentToOrderScreen extends PosComponent { + close() { + // To close this order-indenpendent screen, we forcefully trigger change + // on the selectedOrder attribute, which then shows the screen of the + // current order. + this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order(), { + silent: true, + }); + } + } + + return IndependentToOrderScreen; +}); 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 00000000000..b52a155d3cc --- /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/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js new file mode 100644 index 00000000000..53b858ba492 --- /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 00000000000..913f98af4e6 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js @@ -0,0 +1,48 @@ +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 OrderReceipt = require('point_of_sale.OrderReceipt'); + 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; + + if (this.env.pos.proxy.printer) { + const fixture = document.createElement('div'); + const orderReceipt = new (Registries.Component.get(OrderReceipt))(this, { order }); + await orderReceipt.mount(fixture); + const receiptHtml = orderReceipt.el.outerHTML; + const printResult = await this.env.pos.proxy.printer.print_receipt(receiptHtml); + if (!printResult.successful) { + this.showTempScreen('ReprintReceiptScreen', { order: order }); + } + } else { + this.showTempScreen('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 00000000000..b5766ccf486 --- /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 00000000000..cc0c671c31a --- /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 00000000000..3490a46378a --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js @@ -0,0 +1,193 @@ +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 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(); + } + } + /** + * NOTE: If there is searchDomain, do not show the active orders. + * Unless we want the searchDomain to be applicable to the active orders + * as well. Don't we? + * + * @param {integer|undefined} id id of the cached order + * @returns {Array} + */ + get(id) { + if (id) return this.cache[id]; + if (this.searchDomain) { + return this.ordersToShow.filter((order) => order.locked); + } else { + return this.ordersToShow; + } + } + setSearchDomain(searchDomain) { + this.searchDomain = searchDomain; + } + setComponent(comp) { + this.comp = comp; + this.activeOrders = this.comp.env.pos.get('orders').models; + 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 00000000000..2b4d3cd9654 --- /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} 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 00000000000..951a09561af --- /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 00000000000..f4b80c12190 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js @@ -0,0 +1,102 @@ +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) { + if (order === this.env.pos.get_order()) { + this.close(); + } else { + this.env.pos.set_order(order); + } + } + } + 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 00000000000..959ea5a1477 --- /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 00000000000..78a023a2564 --- /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_product().display_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 00000000000..ef5d396f616 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js @@ -0,0 +1,23 @@ +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 { + confirm() { + this.props.resolve({ confirmed: true, payload: null }); + this.trigger('close-temp-screen'); + } + tryReprint() { + 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/ProductScreen/ActionpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js index 67341d2c84f..d30fe85e956 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js @@ -6,8 +6,8 @@ odoo.define('point_of_sale.ActionpadWidget', function(require) { /** * @props client - * @trigger click-customer - * @trigger click-pay + * @emits click-customer + * @emits click-pay */ class ActionpadWidget extends PosComponent { get isLongName() { 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 index 5658b5b1493..69741a8a090 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js @@ -2,12 +2,13 @@ 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'); - class ProductScreen extends PosComponent { + class ProductScreen extends ControlButtonsMixin(PosComponent) { constructor() { super(...arguments); useListener('update-selected-orderline', this._updateSelectedOrderline); @@ -53,15 +54,6 @@ odoo.define('point_of_sale.ProductScreen', function(require) { get currentOrder() { return this.env.pos.get_order(); } - get controlButtons() { - return ProductScreen.controlButtons - .filter((cb) => { - return cb.condition.bind(this)(); - }) - .map((cb) => - Object.assign({}, cb, { component: Registries.Component.get(cb.component) }) - ); - } async _clickProduct(event) { if (!this.currentOrder) { this.env.pos.add_new_order(); @@ -281,63 +273,6 @@ odoo.define('point_of_sale.ProductScreen', function(require) { } ProductScreen.template = 'ProductScreen'; - ProductScreen.controlButtons = []; - - /** - * @param {Object} controlButton - * @param {Function} controlButton.component - * Can be any base class or base class callback that is added in the Registries.Component. - * @param {Function} controlButton.condition zero argument function that is bound - * to the instance of ProductScreen, such that `this.env.pos` can be used - * inside the function. - * @param {Array} [controlButton.position] array of two elements - * [locator, relativeTo] - * locator: string -> any of ('before', 'after', 'replace') - * relativeTo: string -> other controlButtons component name - */ - ProductScreen.addControlButton = function(controlButton) { - // We set the name first. - if (!controlButton.name) { - controlButton.name = controlButton.component.name; - } - - // If no position is set, we just push it to the array. - if (!controlButton.position) { - this.controlButtons.push(controlButton); - } else { - // Find where to put the new controlButton. - const [locator, relativeTo] = controlButton.position; - let whereIndex = -1; - for (let i = 0; i < this.controlButtons.length; i++) { - if (this.controlButtons[i].name === relativeTo) { - if (['before', 'replace'].includes(locator)) { - whereIndex = i; - } else if (locator === 'after') { - whereIndex = i + 1; - } - break; - } - } - - // If found where to put, then perform the necessary mutation of - // the buttons array. - // Else, we just push this controlButton to the array. - if (whereIndex > -1) { - this.controlButtons.splice( - whereIndex, - locator === 'replace' ? 1 : 0, - controlButton - ); - } else { - let warningMessage = - `'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` + - 'It is pushed to the controlButtons stack instead.'; - console.warn(warningMessage); - this.controlButtons.push(controlButton); - } - } - }; - Registries.Component.add(ProductScreen); return ProductScreen; diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js index 8faa786c661..a5c9adfbc5d 100644 --- a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -1,132 +1,92 @@ -odoo.define('point_of_sale.ReceiptScreen', function(require) { +odoo.define('point_of_sale.ReceiptScreen', function (require) { 'use strict'; - const core = require('web.core'); - const { useRef, useState } = owl.hooks; - const PosComponent = require('point_of_sale.PosComponent'); + const { useState } = 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 _t = core._t; - - class ReceiptScreen extends PosComponent { - /** - * Optional props: - * printInvoiceIsShown: Boolean - */ - constructor() { - super(...arguments); - useErrorHandlers(); - onChangeOrder(null, (newOrder) => newOrder && this.render()); - this.state = useState({ printInvoiceIsShown: this.props.printInvoiceIsShown }); - this.orderReceipt = useRef('order-receipt'); - } - mounted() { - // Here, we send a task to the event loop that handles - // the printing of the receipt when the component is mounted. - // We are doing this because we want the receipt screen to be - // displayed regardless of what happen to the handleAutoPrint - // call. - setTimeout(async () => await this.handleAutoPrint(), 0); - } - get change() { - return this.env.pos.format_currency(this.currentOrder.get_change()); - } - get currentOrder() { - return this.env.pos.get_order(); - } - get nextScreen() { - return { name: 'ProductScreen' }; - } - /** - * This function is called outside the rendering call stack. This way, - * we don't block the displaying of ReceiptScreen when it is mounted; additionally, - * any error that can happen during the printing does not affect the rendering. - */ - async handleAutoPrint() { - if (this._shouldAutoPrint() && !this.currentOrder.is_to_email()) { - await this.printReceipt(); - if (this.currentOrder._printed && this._shouldCloseImmediately()) { - this.orderDone(); - } + const ReceiptScreen = (AbstractReceiptScreen) => { + class ReceiptScreen extends AbstractReceiptScreen { + /** + * Optional props: + * printInvoiceIsShown: Boolean + */ + constructor() { + super(...arguments); + useErrorHandlers(); + onChangeOrder(null, (newOrder) => newOrder && this.render()); + this.state = useState({ printInvoiceIsShown: this.props.printInvoiceIsShown }); } - } - orderDone() { - this.currentOrder.finalize(); - const { name, props } = this.nextScreen; - this.showScreen(name, props); - } - async printReceipt() { - if (this.env.pos.proxy.printer) { - const printResult = await this.env.pos.proxy.printer.print_receipt( - this.orderReceipt.el.outerHTML - ); - if (printResult.successful) { - this.currentOrder._printed = true; - } else { - await this.showPopup('ErrorPopup', { - title: printResult.message.title, - body: printResult.message.body, - }); - } - } else { - await this._printWeb(); + 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 onPrintInvoice() { - // The button element of this event handler appears when the order - // failed to sync and is to be invoiced. What we do here is to try - // to sync again to eventually print the invoice. - try { - await this.env.pos.push_and_invoice_order(this.currentOrder); - this.state.printInvoiceIsShown = false; - } catch (error) { - if (error instanceof Error) { - throw error; - } else { - await this._handlePushOrderError(error); - } + get change() { + return this.env.pos.format_currency(this.currentOrder.get_change()); } - } - _shouldAutoPrint() { - return this.env.pos.config.iface_print_auto && !this.currentOrder._printed; - } - _shouldCloseImmediately() { - var invoiced_finalized = this.currentOrder.is_to_invoice() - ? this.currentOrder.finalized - : true; - return ( - this.env.pos.proxy.printer && - this.env.pos.config.iface_print_skip_screen && - invoiced_finalized - ); - } - async _printWeb() { - if ($.browser.safari) { - document.execCommand('print', false, null); - } else { - try { - window.print(); - this.currentOrder._printed = true; - } catch (err) { - if (navigator.userAgent.toLowerCase().indexOf('android') > -1) { - await this.showPopup('ErrorPopup', { - title: _t('Printing is not supported on some android browsers'), - body: _t( - 'Printing is not supported on some android browsers due to no default printing protocol ' + - 'is available. It is possible to print your tickets by making use of an IoT Box.' - ), - }); - } else { - throw err; + get currentOrder() { + return this.env.pos.get_order(); + } + get nextScreen() { + return { name: 'ProductScreen' }; + } + /** + * This function is called outside the rendering call stack. This way, + * we don't block the displaying of ReceiptScreen when it is mounted; additionally, + * any error that can happen during the printing does not affect the rendering. + */ + async handleAutoPrint() { + if (this._shouldAutoPrint() && !this.currentOrder.is_to_email()) { + await this.printReceipt(); + if (this.currentOrder._printed && this._shouldCloseImmediately()) { + this.orderDone(); } } } + orderDone() { + this.currentOrder.finalize(); + const { name, props } = this.nextScreen; + this.showScreen(name, props); + } + async printReceipt() { + const isPrinted = await this._printReceipt(); + if (isPrinted) { + this.currentOrder._printed = true; + } + } + async onPrintInvoice() { + // The button element of this event handler appears when the order + // failed to sync and is to be invoiced. What we do here is to try + // to sync again to eventually print the invoice. + try { + await this.env.pos.push_and_invoice_order(this.currentOrder); + this.state.printInvoiceIsShown = false; + } catch (error) { + if (error instanceof Error) { + throw error; + } else { + await this._handlePushOrderError(error); + } + } + } + _shouldAutoPrint() { + return this.env.pos.config.iface_print_auto && !this.currentOrder._printed; + } + _shouldCloseImmediately() { + var invoiced_finalized = this.currentOrder.is_to_invoice() ? this.currentOrder.finalized : true; + return this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized; + } } - } - ReceiptScreen.template = 'ReceiptScreen'; + ReceiptScreen.template = 'ReceiptScreen'; + return ReceiptScreen; + }; - Registries.Component.add(ReceiptScreen); + Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen); return ReceiptScreen; }); diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 8f6e419f557..9875d3030ab 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -2312,10 +2312,12 @@ exports.Paymentline = Backbone.Model.extend({ 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.transaction_id = json.transaction_id; + this.is_change = json.is_change; }, //sets the amount of money on this payment line set_amount: function(value){ @@ -2391,7 +2393,8 @@ exports.Paymentline = Backbone.Model.extend({ return { cid: this.cid, amount: this.get_amount(), - payment_method: this.payment_method.name, + name: this.name, + ticket: this.ticket, }; }, }); @@ -2410,7 +2413,7 @@ exports.Order = Backbone.Model.extend({ var self = this; options = options || {}; - this.init_locked = true; + this.locked = false; this.pos = options.pos; this.selected_orderline = undefined; this.selected_paymentline = undefined; @@ -2453,13 +2456,12 @@ exports.Order = Backbone.Model.extend({ this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos); } - this.init_locked = false; this.save_to_db(); return this; }, save_to_db: function(){ - if (!this.temporary && !this.init_locked) { + if (!this.temporary && !this.locked) { this.pos.db.save_unpaid_order(this); } }, @@ -2536,6 +2538,14 @@ exports.Order = Backbone.Model.extend({ 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; }, export_as_JSON: function() { var orderLines, paymentLines; @@ -2552,7 +2562,7 @@ exports.Order = Backbone.Model.extend({ 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(), + amount_return: this.amount_return ? this.amount_return : this.get_change(), lines: orderLines, statement_ids: paymentLines, pos_session_id: this.pos_session_id, @@ -2580,10 +2590,16 @@ exports.Order = Backbone.Model.extend({ orderlines.push(orderline.export_for_printing()); }); - var paymentlines = []; - this.paymentlines.each(function(paymentline){ - paymentlines.push(paymentline.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; @@ -2619,7 +2635,7 @@ exports.Order = Backbone.Model.extend({ total_discount: this.get_total_discount(), rounding_applied: this.get_rounding_applied(), tax_details: this.get_tax_details(), - change: this.get_change(), + change: this.locked ? this.amount_return : this.get_change(), name : this.get_name(), client: client ? client : null , invoice_id: null, //TODO diff --git a/addons/point_of_sale/static/src/js/printers.js b/addons/point_of_sale/static/src/js/printers.js index b0332f604c9..4c2b465828d 100644 --- a/addons/point_of_sale/static/src/js/printers.js +++ b/addons/point_of_sale/static/src/js/printers.js @@ -68,7 +68,7 @@ var PrinterMixin = { } // rpc call is okay but printing failed because // IoT box can't find a printer. - if (!sendPrintResult) { + if (!sendPrintResult || (sendPrintResult && !sendPrintResult.result)) { this.receipt_queue.length = 0; return this.printResultGenerator.IoTResultError(); } diff --git a/addons/point_of_sale/static/src/js/utils.js b/addons/point_of_sale/static/src/js/utils.js index ae698ab60d3..26442ef776b 100644 --- a/addons/point_of_sale/static/src/js/utils.js +++ b/addons/point_of_sale/static/src/js/utils.js @@ -1,4 +1,4 @@ -odoo.define('point_of_sale.utils', function(require) { +odoo.define('point_of_sale.utils', function (require) { 'use strict'; function getFileAsText(file) { @@ -7,7 +7,7 @@ odoo.define('point_of_sale.utils', function(require) { reject(); } else { const reader = new FileReader(); - reader.addEventListener('load', function() { + reader.addEventListener('load', function () { resolve(reader.result); }); reader.addEventListener('abort', reject); @@ -17,5 +17,31 @@ odoo.define('point_of_sale.utils', function(require) { }); } - return { getFileAsText }; + /** + * 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 }; }); diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml index f99905f8024..243641a5592 100644 --- a/addons/point_of_sale/static/src/xml/Chrome.xml +++ b/addons/point_of_sale/static/src/xml/Chrome.xml @@ -16,6 +16,7 @@ + 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 00000000000..062e11c3f49 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml @@ -0,0 +1,12 @@ + + + + +
+
+ +
+
+
+ +
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml index 000a743da04..353ea5f93a4 100644 --- a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml @@ -1,8 +1,8 @@ - -
+
+ @@ -28,7 +28,7 @@ -
- + +
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 00000000000..72c85188c2e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml @@ -0,0 +1,12 @@ + + + + +
+ + + +
+
+ +
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 00000000000..df3e4e0611c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml @@ -0,0 +1,12 @@ + + + + +
+ + + Print Receipt +
+
+ +
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 00000000000..479b35a4ca4 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml @@ -0,0 +1,30 @@ + + + +
+
+ +
+
+ + + +
+
+ + +
+
+
+ Back to list +
+
+
+
+ + +
+
+
+ +
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 00000000000..87579d09a1e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml @@ -0,0 +1,35 @@ + + + + +
+
+
+ +
+ +

Select an order

+
+
+ +
+ +

Order is empty

+
+
+ +
    + + + +
+ +
+
+
+
+
+ +
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 00000000000..865f609b8b2 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml @@ -0,0 +1,20 @@ + + + + +
+
+
Order
+
Date
+
Customer
+
Total
+
+
+ + + +
+
+
+ +
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 00000000000..3a294bfdcd1 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml @@ -0,0 +1,36 @@ + + + + +
+
+ + Back +
+ +
+
+ + +
+
+ +
+
+
+
+
+ +
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 00000000000..8992e2c80ee --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml @@ -0,0 +1,32 @@ + + + + +
+
+
+ +
+
+ + + +
+
+ + +
+
+
+
+
+ + +
+
+
+ +
+
+ +
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 00000000000..29b07cfe736 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml @@ -0,0 +1,15 @@ + + + + +
+
+
+
+
+
+
+ +
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 00000000000..2e6869e5650 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml @@ -0,0 +1,21 @@ + + + + +
  • + + + + + + +
  • + + + + +
  • + +
    + +
    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 00000000000..0a80a0e027a --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml @@ -0,0 +1,26 @@ + + + + +
    +
    +
    + + + + Back + +
    +
    +
    + Print Receipt +
    +
    + +
    +
    +
    +
    +
    + +
    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 index 04e1ab32446..a229c53a86d 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml @@ -9,7 +9,7 @@ -
    +
    Taxes: 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 index ea29ec1a02e..636d97e90d8 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml @@ -124,10 +124,10 @@ - +
    - +

    @@ -174,7 +174,7 @@