diff --git a/addons/point_of_sale/models/pos_combo_line.py b/addons/point_of_sale/models/pos_combo_line.py index 6293b2a427a..2ed444fb66c 100644 --- a/addons/point_of_sale/models/pos_combo_line.py +++ b/addons/point_of_sale/models/pos_combo_line.py @@ -5,7 +5,7 @@ class PosComboLine(models.Model): _name = "pos.combo.line" _description = "Product Combo Items" - product_id = fields.Many2one("product.product", string="Product") + product_id = fields.Many2one("product.product", string="Product", required=True) price = fields.Float("Price Extra", default=0.0) lst_price = fields.Float("Original Price", related="product_id.lst_price") combo_id = fields.Many2one("pos.combo") diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 69e1f454c82..09c973f45b3 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -776,6 +776,7 @@ class PosOrder(models.Model): 'balance': -balance, }) + # sort self.payment_ids by is_split_transaction: for payment_id in self.payment_ids: is_split_transaction = payment_id.payment_method_id.split_transactions if is_split_transaction: @@ -783,7 +784,10 @@ class PosOrder(models.Model): else: reversed_move_receivable_account_id = payment_id.payment_method_id.receivable_account_id or self.company_id.account_default_pos_receivable_account_id - aml_vals_entry_found = [aml_entry for aml_entry in aml_vals_list_per_nature['payment_terms'] if aml_entry['account_id'] == reversed_move_receivable_account_id.id] + aml_vals_entry_found = [aml_entry for aml_entry in aml_vals_list_per_nature['payment_terms'] + if aml_entry['account_id'] == reversed_move_receivable_account_id.id + and not aml_entry['partner_id']] + if aml_vals_entry_found and not is_split_transaction: aml_vals_entry_found[0]['amount_currency'] += self.session_id._amount_converter(payment_id.amount, self.date_order, False) aml_vals_entry_found[0]['balance'] += payment_id.amount diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 8eac73aa7ac..3aa1788b991 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -222,6 +222,17 @@ class PosSession(models.Model): else: sessions = super().create(vals_list) sessions.action_pos_session_open() + + for session in sessions: + session.env['ir.sequence'].sudo().create({ + 'name': _("PoS Order by Session"), + 'padding': 4, + 'code': f'pos.order_{session.id}', + 'number_next': 1, + 'number_increment': 1, + 'company_id': False, + }) + return sessions def unlink(self): diff --git a/addons/point_of_sale/models/res_config_settings.py b/addons/point_of_sale/models/res_config_settings.py index 3fd30d9c66a..26581e20f84 100644 --- a/addons/point_of_sale/models/res_config_settings.py +++ b/addons/point_of_sale/models/res_config_settings.py @@ -40,6 +40,7 @@ class ResConfigSettings(models.TransientModel): account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False) is_default_pricelist_displayed = fields.Boolean(compute="_compute_pos_pricelist_id", compute_sudo=True) barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', related='company_id.nomenclature_id', readonly=False) + is_kiosk_mode = fields.Boolean(string="Is Kiosk Mode", default=False) # pos.config fields pos_module_pos_discount = fields.Boolean(related='pos_config_id.module_pos_discount', readonly=False) diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index 59da5aba01a..8fc9777ca6e 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -1598,10 +1598,11 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): original_payment = self.env['account.move'].search([('ref', '=', current_session.display_name)]) original_customer_payment_entry = original_payment.line_ids.filtered(lambda l: l.account_id.account_type == 'asset_receivable') reverser_customer_payment_entry = reverse_payment.line_ids.filtered(lambda l: l.account_id.account_type == 'asset_receivable') + #check that both use the same account self.assertEqual(len(reverser_customer_payment_entry), 2) - self.assertEqual(reverser_customer_payment_entry[0].balance, -2.0) - self.assertEqual(reverser_customer_payment_entry[1].balance, -4.0) + self.assertEqual(reverser_customer_payment_entry.filtered(lambda l: l.partner_id).balance, -4.0) + self.assertEqual(reverser_customer_payment_entry.filtered(lambda l: not l.partner_id).balance, -2.0) self.assertEqual(original_customer_payment_entry.account_id.id, reverser_customer_payment_entry.account_id.id) self.assertEqual(reverser_customer_payment_entry.partner_id, original_customer_payment_entry.partner_id) diff --git a/addons/point_of_sale/views/pos_session_view.xml b/addons/point_of_sale/views/pos_session_view.xml index 970a4789885..010f3111dbf 100644 --- a/addons/point_of_sale/views/pos_session_view.xml +++ b/addons/point_of_sale/views/pos_session_view.xml @@ -7,7 +7,7 @@
+ +
- + - -
@@ -49,6 +49,61 @@
+ + + + +
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + + + + +
+
+
+
+
+
+ +
@@ -56,7 +111,7 @@
- +
@@ -76,7 +131,7 @@ - + @@ -91,7 +146,6 @@
-
- +
@@ -168,7 +222,7 @@
- + @@ -179,7 +233,7 @@ target="_blank" class="oe-link">How to manage tax-included prices - + @@ -228,57 +282,6 @@
- - - -
-
-
- - - - - -
-
-
-
-
- -
-
-
-
-
-
- - -
-
-
- - - - - -
-
-
-
-
-
- diff --git a/addons/pos_adyen/controllers/main.py b/addons/pos_adyen/controllers/main.py index ec988da38d6..0cbf7983eda 100644 --- a/addons/pos_adyen/controllers/main.py +++ b/addons/pos_adyen/controllers/main.py @@ -2,7 +2,7 @@ import logging import pprint import json -from odoo import fields, http +from odoo import http from odoo.http import request _logger = logging.getLogger(__name__) diff --git a/addons/pos_loyalty/views/res_config_settings_view.xml b/addons/pos_loyalty/views/res_config_settings_view.xml index c524c517322..ce831cd3cfe 100644 --- a/addons/pos_loyalty/views/res_config_settings_view.xml +++ b/addons/pos_loyalty/views/res_config_settings_view.xml @@ -7,13 +7,13 @@
-
+
-
+
diff --git a/addons/pos_online_payment_self_order/__manifest__.py b/addons/pos_online_payment_self_order/__manifest__.py index 38c5dc42cda..edde1a3278e 100644 --- a/addons/pos_online_payment_self_order/__manifest__.py +++ b/addons/pos_online_payment_self_order/__manifest__.py @@ -14,10 +14,10 @@ 'installable': True, 'auto_install': True, 'assets': { - 'pos_self_order.assets_self_order': [ + 'pos_self_order.assets_mobile': [ 'pos_online_payment_self_order/static/src/**/*', ], - 'pos_self_order.assets_tests': [ + 'pos_self_order.assets_mobile_tests': [ 'pos_online_payment_self_order/static/tests/tours/**/*', ], }, diff --git a/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js b/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js index f1a9c3c412a..db22d9592d2 100644 --- a/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js +++ b/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js @@ -1,6 +1,6 @@ /** @odoo-module */ import { patch } from "@web/core/utils/patch"; -import { OrderCart } from "@pos_self_order/pages/order_cart/order_cart"; +import { OrderCart } from "@pos_self_order/mobile/pages/order_cart/order_cart"; import { _t } from "@web/core/l10n/translation"; patch(OrderCart.prototype, { diff --git a/addons/pos_online_payment_self_order/static/src/self_order_service.js b/addons/pos_online_payment_self_order/static/src/self_order_service.js index 8fef6d060e2..cd133f6621b 100644 --- a/addons/pos_online_payment_self_order/static/src/self_order_service.js +++ b/addons/pos_online_payment_self_order/static/src/self_order_service.js @@ -1,6 +1,6 @@ /** @odoo-module */ import { patch } from "@web/core/utils/patch"; -import { SelfOrder } from "@pos_self_order/self_order_service"; +import { SelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { session } from "@web/session"; patch(SelfOrder.prototype, { diff --git a/addons/pos_online_payment_self_order/views/res_config_settings_views.xml b/addons/pos_online_payment_self_order/views/res_config_settings_views.xml index 2ab222baeb2..0d2440a2b0b 100644 --- a/addons/pos_online_payment_self_order/views/res_config_settings_views.xml +++ b/addons/pos_online_payment_self_order/views/res_config_settings_views.xml @@ -3,7 +3,7 @@ res.config.settings.view.form.inherit.pos_online_payment.view res.config.settings - +
diff --git a/addons/pos_restaurant/models/pos_config.py b/addons/pos_restaurant/models/pos_config.py index 2015029eeb1..4c3f75234ca 100644 --- a/addons/pos_restaurant/models/pos_config.py +++ b/addons/pos_restaurant/models/pos_config.py @@ -33,7 +33,7 @@ class PosConfig(models.Model): # we ignore last_order_preparation_change when it is set to false. # In future, pos_self_order will send the various changes to the order. if not line.order_id.last_order_preparation_change: - continue + line.order_id.last_order_preparation_change = '{}' last_order_preparation_change = json.loads(line.order_id.last_order_preparation_change) prep_change = {} diff --git a/addons/pos_restaurant/views/res_config_settings_views.xml b/addons/pos_restaurant/views/res_config_settings_views.xml index 8d688b56b3b..3580ecf82b0 100644 --- a/addons/pos_restaurant/views/res_config_settings_views.xml +++ b/addons/pos_restaurant/views/res_config_settings_views.xml @@ -6,37 +6,35 @@
- - - -
-
-
-
-
+ + +
+
+
- - - - - - - - - - - - -
-
- Save this page and come back here to set up the feature. -
+
+
- - +
+
+ + + + + + + + + + + +
+
+ Save this page and come back here to set up the feature. +
+
+
diff --git a/addons/pos_sale/views/res_config_settings_views.xml b/addons/pos_sale/views/res_config_settings_views.xml index 5bb0915bf6d..d610c40702e 100644 --- a/addons/pos_sale/views/res_config_settings_views.xml +++ b/addons/pos_sale/views/res_config_settings_views.xml @@ -11,7 +11,7 @@ - + ", "/kiosk//"], auth="public", website=True, sitemap=True) + def pos_self_order_kiosk_start(self, config_id=None, access_token=None): + if not config_id or not access_token: + raise werkzeug.exceptions.NotFound() + + pos_config_sudo = request.env["pos.config"].sudo().search([ + ("id", "=", config_id), + ('access_token', '=', access_token)], limit=1) + + if not pos_config_sudo: + raise werkzeug.exceptions.NotFound() + + company = pos_config_sudo.company_id + user = pos_config_sudo.current_session_id.user_id + pos_config = reduce_privilege(pos_config_sudo, company, user) + session_info = request.env["ir.http"].get_frontend_session_info() + + return request.render( + 'pos_self_order.kiosk_index', + { + 'session_info': { + **session_info, + 'currencies': request.env["ir.http"].get_currencies(), + 'pos_self_order_data': { + 'access_token': pos_config.access_token, + **pos_config._get_self_order_kiosk_data(), + }, + } + } + ) + + @http.route( + "/kiosk/get-category-image/", + methods=["GET"], + type="http", + auth="public", + ) + def pos_self_order_get_image(self, category_id: int): + category = request.env["pos.category"].sudo().browse(category_id) + + if not category.has_image: + raise werkzeug.exceptions.NotFound() + + return ( + request.env["ir.binary"] + ._get_image_stream_from( + category, + field_name="image_128", + ) + .get_response() + ) diff --git a/addons/pos_self_order/controllers/entry.py b/addons/pos_self_order/controllers/mobile_entry.py similarity index 74% rename from addons/pos_self_order/controllers/entry.py rename to addons/pos_self_order/controllers/mobile_entry.py index 2f352313831..d3940e662d7 100644 --- a/addons/pos_self_order/controllers/entry.py +++ b/addons/pos_self_order/controllers/mobile_entry.py @@ -33,12 +33,18 @@ class PosQRMenuController(http.Controller): def pos_self_order_start(self, config_id=None, access_token=None, table_identifier=None): pos_config_sudo = False table_sudo = False + config_access_token = False if config_id and config_id.isnumeric() and access_token: pos_config_sudo = request.env["pos.config"].sudo().search([ ("id", "=", config_id), ('access_token', '=', access_token)], limit=1) + if pos_config_sudo: + config_access_token = pos_config_sudo.access_token + else: + raise werkzeug.exceptions.Unauthorized() + if pos_config_sudo and pos_config_sudo.has_active_session and pos_config_sudo.self_order_table_mode: table_sudo = table_identifier and ( request.env["restaurant.table"] @@ -60,7 +66,7 @@ class PosQRMenuController(http.Controller): raise werkzeug.exceptions.NotFound() return request.render( - 'pos_self_order.index', + 'pos_self_order.mobile_index', { 'session_info': { **request.env["ir.http"].get_frontend_session_info(), @@ -68,8 +74,8 @@ class PosQRMenuController(http.Controller): 'pos_self_order_data': { 'self_order_mode': pos_config.self_order_pay_after, 'table': table._get_self_order_data() if table else False, - 'access_token': pos_config.access_token, - **pos_config._get_self_order_data(), + 'access_token': config_access_token, + **pos_config._get_self_order_mobile_data(), }, "base_url": request.env['pos.session'].get_base_url(), } @@ -85,13 +91,9 @@ class PosQRMenuController(http.Controller): type="http", auth="public", ) - def pos_self_order_get_image(self, product_id: int, image_size: Optional[int] = 128): - """ - This is the route that the POS Self Order App uses to GET THE PRODUCT IMAGES. - :return: the image of the product (or a default one in case no product is found) - or an exception if there is no pos configured as self order. - :rtype: binary - """ + def pos_self_order_get_image(self, product_id, image_size=128, **kw): + # This controller is public and does not require an access code (access_token) because the user + # needs to see the product image in "menu" mode. In this mode, the user has no access_token. if not get_any_pos_config_sudo(): raise werkzeug.exceptions.Unauthorized() @@ -103,23 +105,3 @@ class PosQRMenuController(http.Controller): ) .get_response() ) - - @http.route( - "/menu/get-bg-image/", methods=["GET"], type="http", auth="public" - ) - def pos_self_order_get_bg_image(self, pos_config_id: int): - """ - Gets the background image for this self order - :return: the bg image - :rtype: binary - """ - pos_config_sudo = request.env["pos.config"].sudo().browse(pos_config_id) - - if not pos_config_sudo.self_order_image: - raise werkzeug.exceptions.NotFound() - - return ( - request.env["ir.binary"] - ._get_image_stream_from(pos_config_sudo, field_name="self_order_image") - .get_response() - ) diff --git a/addons/pos_self_order/controllers/orders.py b/addons/pos_self_order/controllers/orders.py index 7ff30a0bc1b..45f6958b117 100644 --- a/addons/pos_self_order/controllers/orders.py +++ b/addons/pos_self_order/controllers/orders.py @@ -1,30 +1,31 @@ # -*- coding: utf-8 -*- - -from datetime import timedelta +import re import uuid -from odoo import http, fields, Command +from datetime import timedelta +from odoo import http, fields from odoo.http import request from odoo.addons.pos_self_order.controllers.utils import reduce_privilege from werkzeug.exceptions import NotFound, BadRequest, Unauthorized class PosSelfOrderController(http.Controller): - @http.route("/pos-self-order/process-new-order", auth="public", type="json", website=True) - def process_new_order(self, order, access_token, table_identifier): + @http.route("/pos-self-order/process-new-order//", auth="public", type="json", website=True) + def process_new_order(self, order, access_token, table_identifier, device_type): lines = order.get('lines') pos_config, table = self._verify_authorization(access_token, table_identifier) pos_session = pos_config.current_session_id - sequence_number = self._get_sequence_number(table.id, pos_session.id) - unique_id = self._generate_unique_id(pos_session.id, table.id, sequence_number) + ir_sequence = pos_config.env['ir.sequence'].with_context(company_id=pos_config.company_id.id).next_by_code(f'pos.order_{pos_session.id}') + sequence_number = re.findall(r'\d+', ir_sequence)[0] + order_reference = self._generate_unique_id(pos_session.id, table.id if table else 0, sequence_number, device_type) # Create the order without lines and prices computed # We need to remap the order because some required fields are not used in the frontend. order = { - 'id': unique_id, 'data': { - 'uuid': order.get('uuid'), - 'name': unique_id, - 'user_id': request.session.uid, + 'name': order_reference, 'sequence_number': sequence_number, + 'uuid': order.get('uuid'), + 'take_away': order.get('take_away'), + 'user_id': request.session.uid, 'access_token': uuid.uuid4().hex, 'pos_session_id': pos_session.id, 'table_id': table.id if table else False, @@ -37,6 +38,7 @@ class PosSelfOrderController(http.Controller): 'amount_total': 0, 'amount_paid': 0, 'amount_return': 0, + 'tracking_number': self._generate_unique_tracking_number(pos_config, order), # FIXME this is not unique }, 'to_invoice': False, 'session_id': pos_session.id, @@ -46,30 +48,45 @@ class PosSelfOrderController(http.Controller): posted_order_id = pos_config.env['pos.order'].create_from_ui([order], draft=True)[0].get('id') # Process the lines and get their prices computed - lines = self._process_lines(lines, pos_config, posted_order_id) + lines = self._process_lines(lines, pos_config, posted_order_id, order.get('take_away')) # Compute the order prices amount_total, amount_untaxed = self._get_order_prices(lines) # Update the order with the computed prices and lines order = pos_config.env["pos.order"].browse(posted_order_id) + + classic_lines = [] + combo_lines = [] + for line in lines: + if line["combo_parent_uuid"]: + combo_lines.append(line) + else: + classic_lines.append(line) + + # combo lines must be created after classic_line, as they need the classic line identifier + lines = pos_config.env['pos.order.line'].create(classic_lines) + lines += pos_config.env['pos.order.line'].create(combo_lines) order.write({ - 'lines': [Command.create(line) for line in lines], + 'lines': lines, 'amount_tax': amount_total - amount_untaxed, 'amount_total': amount_total, }) + order.send_table_count_notification(order.table_id) return order._export_for_self_order() @http.route('/pos-self-order/get-orders-taxes', auth='public', type='json', website=True) def get_order_taxes(self, order, access_token): pos_config = self._verify_pos_config(access_token) - lines = self._process_lines(order.get('lines'), pos_config, 0) + lines = self._process_lines(order.get('lines'), pos_config, 0, order.get('take_away')) amount_total, amount_untaxed = self._get_order_prices(lines) return { 'lines': [{ 'uuid': line.get('uuid'), + 'price_unit': line.get('price_unit'), + 'price_extra': line.get('price_extra'), 'price_subtotal': line.get('price_subtotal'), 'price_subtotal_incl': line.get('price_subtotal_incl'), } for line in lines], @@ -87,7 +104,6 @@ class PosSelfOrderController(http.Controller): pos_order = session.order_ids.filtered_domain([ ('id', '=', order_id), ('access_token', '=', order_access_token), - ('table_id', '=', table.id) ]) if not pos_order: @@ -95,17 +111,19 @@ class PosSelfOrderController(http.Controller): elif pos_order.state != 'draft': raise Unauthorized("Order is not in draft state") - lines = self._process_lines(order.get('lines'), pos_config, pos_order.id) + lines = self._process_lines(order.get('lines'), pos_config, pos_order.id, order.get('take_away')) for line in lines: if line.get('id'): - order_line = pos_order.lines.browse(line.get('id')) + # we need to find by uuid because each time we update the order, id of orderlines changed. + order_line = pos_order.lines.filtered(lambda l: l.uuid == line.get('uuid')) if line.get('qty') < order_line.qty: line.set('qty', order_line.qty) - order_line.write({ - **line, - }) + if order_line: + order_line.write({ + **line, + }) else: pos_order.lines.create(line) @@ -114,7 +132,7 @@ class PosSelfOrderController(http.Controller): 'amount_tax': amount_total - amount_untaxed, 'amount_total': amount_total, }) - + pos_order.send_table_count_notification(pos_order.table_id) return pos_order._export_for_self_order() @http.route('/pos-self-order/get-orders', auth='public', type='json', website=True) @@ -129,11 +147,11 @@ class PosSelfOrderController(http.Controller): if not orders: raise NotFound("Orders not found") - orders = [] + orders_for_ui = [] for order in orders: - orders.append(order._export_for_self_order()) + orders_for_ui.append(order._export_for_self_order()) - return orders + return orders_for_ui @http.route('/pos-self-order/get-tables', auth='public', type='json', website=True) def get_tables(self, access_token): @@ -145,18 +163,100 @@ class PosSelfOrderController(http.Controller): return tables - def _process_lines(self, lines, pos_config, pos_order_id): + + @http.route('/kiosk/payment//', auth='public', type='json', website=True) + def pos_self_order_kiosk_payment(self, pos_config_id, order, payment_method_id, access_token, device_type): + order_dict = self.process_new_order(order, access_token, None, device_type) + + if not order_dict.get('id'): + raise BadRequest("Something went wrong") + + # access_token verified in process_new_order + order_sudo = request.env['pos.order'].sudo().browse(order_dict.get('id')) + payment_method_sudo = request.env["pos.payment.method"].sudo().browse(payment_method_id) + if not order_sudo or not payment_method_sudo or payment_method_sudo not in order_sudo.config_id.payment_method_ids: + raise NotFound("Order or payment method not found") + + status = payment_method_sudo.payment_request_from_kiosk(order_sudo) + + if not status: + raise BadRequest("Something went wrong") + + return order_sudo._export_for_self_order() + + def _generate_unique_tracking_number(self, pos_config, order_dict): + last_tracking_generated = pos_config.env['pos.order'].search([ + ('tracking_number', '!=', False), + ('date_order', '>=', fields.Datetime.now() - timedelta(days=1)), + ], order='tracking_number desc', limit=1) + + return int(last_tracking_generated.tracking_number) + 1 if last_tracking_generated else 1 + + def _process_lines(self, lines, pos_config, pos_order_id, take_away=False): + appended_uuid = [] newLines = [] pricelist = pos_config.pricelist_id + if take_away and pos_config.self_order_kiosk: + config_fiscal_pos = pos_config.self_order_kiosk_alternative_fp_id + else: + config_fiscal_pos = pos_config.default_fiscal_position_id for line in lines: - product = pos_config.env['product.product'].browse(line.get('product_id')) + if line.get('uuid') in appended_uuid: + continue + product = pos_config.env['product.product'].browse(int(line.get('product_id'))) # todo take into account the price extra price_unit = pricelist._get_product_price(product, quantity=line.get('qty')) if pricelist else product.lst_price - - config_fiscal_pos = pos_config.default_fiscal_position_id selected_account_tax = config_fiscal_pos.map_tax(product.taxes_id) if config_fiscal_pos else product.taxes_id + # parent_product_taxe_ids = None + children = [l for l in lines if l.get('combo_parent_uuid') == line.get('uuid')] + if len(children) > 0: + total_price = 0 + unit_price_by_id = {} + for child in children: + product = pos_config.env['product.product'].browse(int(child.get('product_id'))) + child_selected_account_tax = config_fiscal_pos.map_tax(product.taxes_id) if config_fiscal_pos else product.taxes_id + tax_results = child_selected_account_tax.compute_all( + pricelist._get_product_price(product, quantity=line.get('qty')) if pricelist else product.lst_price, + pos_config.currency_id, + child.get('qty'), + product, + ) + unit_price_by_id[child['uuid']] = pricelist._get_product_price(product, quantity=line.get('qty')) if pricelist else product.lst_price + total_price += tax_results.get('total_included') if pos_config.iface_tax_included == 'total' else tax_results.get('total_excluded') + ratio = (price_unit / total_price) + for child in children: + child_line_combo = pos_config.env['pos.combo'].browse(int(child.get('combo_id'))) + child_line_combo_line = child_line_combo.combo_line_ids.filtered(lambda l: l.product_id.id == child.get('product_id'))[0] + child_product = pos_config.env["product.product"].browse(int(child.get('product_id'))) + child_price_unit = ratio * unit_price_by_id[child['uuid']] + child_line_combo_line.price + child_selected_account_tax = config_fiscal_pos.map_tax(child_product.taxes_id) if config_fiscal_pos else child_product.taxes_id + child_tax_results = child_selected_account_tax.compute_all( + child_price_unit, + pos_config.currency_id, + child.get('qty'), + child_product, + ) + newLines.append({ + 'price_unit': price_unit, + 'price_subtotal': child_tax_results.get('total_excluded'), + 'price_subtotal_incl': child_tax_results.get('total_included'), + 'id': child.get('id'), + 'order_id': pos_order_id, + 'tax_ids': product.taxes_id, + 'uuid': child.get('uuid'), + 'product_id': child.get('product_id'), + 'qty': child.get('qty'), + 'customer_note': child.get('customer_note'), + 'selected_attributes': child.get('selected_attributes'), + 'full_product_name': child.get('full_product_name'), + 'combo_parent_uuid': child.get('combo_parent_uuid'), + 'combo_id': child.get('combo_id'), + }) + appended_uuid.append(child.get('uuid')) + price_unit = 0 + tax_results = selected_account_tax.compute_all( price_unit, pos_config.currency_id, @@ -168,7 +268,6 @@ class PosSelfOrderController(http.Controller): 'price_unit': price_unit, 'price_subtotal': tax_results.get('total_excluded'), 'price_subtotal_incl': tax_results.get('total_included'), - 'price_extra': 0, 'id': line.get('id'), 'order_id': pos_order_id, 'tax_ids': product.taxes_id, @@ -178,7 +277,10 @@ class PosSelfOrderController(http.Controller): 'customer_note': line.get('customer_note'), 'selected_attributes': line.get('selected_attributes'), 'full_product_name': line.get('full_product_name'), + 'combo_parent_uuid': line.get('combo_parent_uuid'), + 'combo_id': line.get('combo_id'), }) + appended_uuid.append(line.get('uuid')) return newLines @@ -192,20 +294,13 @@ class PosSelfOrderController(http.Controller): # Last part the sequence number of the order. # INFO: This is allow a maximum of 999 tables and 9999 orders per table, so about ~1M orders per session. # Example: 'Self-Order 00001-001-0001' - def _generate_unique_id(self, pos_session_id: int, table_id: int, sequence_number: int) -> str: + def _generate_unique_id(self, pos_session_id, table_id, sequence_number, device_type): first_part = "{:05d}".format(int(pos_session_id)) second_part = "{:03d}".format(int(table_id)) third_part = "{:04d}".format(int(sequence_number)) - return f"Self-Order {first_part}-{second_part}-{third_part}" - - def _get_sequence_number(self, table_id, session_id): - order_sudo = request.env["pos.order"].sudo().search([( - 'pos_reference', - 'like', - f"Self-Order {session_id:0>5}-{table_id:0>3}")], order='id desc', limit=1) - - return (order_sudo.sequence_number + 1) or 1 + device = "Kiosk" if device_type == "kiosk" else "Self-Order" + return f"{device} {first_part}-{second_part}-{third_part}" def _verify_pos_config(self, access_token): """ @@ -213,7 +308,7 @@ class PosSelfOrderController(http.Controller): The record is has no sudo access and is in the context of the record's company and current pos.session's user. """ pos_config_sudo = request.env['pos.config'].sudo().search([('access_token', '=', access_token)], limit=1) - if not pos_config_sudo or not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session: + if not pos_config_sudo or (not pos_config_sudo.self_order_table_mode and not pos_config_sudo.self_order_kiosk) or not pos_config_sudo.has_active_session: raise Unauthorized("Invalid access token") company = pos_config_sudo.company_id user = pos_config_sudo.current_session_id.user_id @@ -224,11 +319,12 @@ class PosSelfOrderController(http.Controller): Similar to _verify_pos_config but also looks for the restaurant.table of the given identifier. The restaurant.table record is also returned with reduced privileges. """ + pos_config = self._verify_pos_config(access_token) table_sudo = request.env["restaurant.table"].sudo().search([('identifier', '=', table_identifier)], limit=1) - if not table_sudo: + + if not table_sudo and not pos_config.self_order_kiosk: raise Unauthorized("Table not found") - pos_config = self._verify_pos_config(access_token) company = pos_config.company_id user = pos_config.current_session_id.user_id table = reduce_privilege(table_sudo, company, user) diff --git a/addons/pos_self_order/controllers/utils.py b/addons/pos_self_order/controllers/utils.py index 8aed1a0c58c..9d4ebd5edb9 100644 --- a/addons/pos_self_order/controllers/utils.py +++ b/addons/pos_self_order/controllers/utils.py @@ -12,7 +12,7 @@ def get_any_pos_config_sudo() -> PosConfig: or raises a NotFound otherwise """ return ( - request.env["pos.config"].sudo().search([("self_order_view_mode", "=", True)], limit=1) + request.env["pos.config"].sudo().search([('|'), ("self_order_view_mode", "=", True), ("self_order_kiosk", "=", True)], limit=1) ) or _raise(werkzeug.exceptions.NotFound()) def _raise(e): diff --git a/addons/pos_self_order/models/__init__.py b/addons/pos_self_order/models/__init__.py index 3b6f3f5d121..adbaac71920 100644 --- a/addons/pos_self_order/models/__init__.py +++ b/addons/pos_self_order/models/__init__.py @@ -4,6 +4,7 @@ from . import ir_http from . import pos_config from . import pos_order from . import pos_restaurant +from . import pos_payment_method from . import pos_self_order_custom_link from . import product_product from . import res_config_settings diff --git a/addons/pos_self_order/models/pos_config.py b/addons/pos_self_order/models/pos_config.py index 844170d39b5..9db21abc961 100644 --- a/addons/pos_self_order/models/pos_config.py +++ b/addons/pos_self_order/models/pos_config.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from typing import Optional, List, Dict, Callable +import io import uuid -from werkzeug.urls import url_quote import base64 +from PIL import Image +from typing import Optional, List, Dict +from werkzeug.urls import url_quote +from odoo.tools import image_to_base64 - -from odoo import api, fields, models, modules +from odoo import api, fields, models, modules, _ from odoo.tools import file_open, split_every -from odoo.addons.pos_self_order.models.product_product import ProductProduct - class PosConfig(models.Model): _inherit = "pos.config" @@ -25,6 +25,84 @@ class PosConfig(models.Model): ) return base64.b64encode(file_open(image_path, "rb").read()) + def _self_order_kiosk_default_languages(self): + return self.env["res.lang"].get_installed() + + status = fields.Selection( + [("inactive", "Inactive"), ("active", "Active")], + string="Status", + compute="_compute_status", + store=False, + ) + self_order_kiosk_url = fields.Char(compute="_compute_self_order_kiosk_url") + self_order_kiosk = fields.Boolean( + string="Kiosk", + help="Enable the kiosk mode for the Point of Sale", + ) + self_order_kiosk_takeaway = fields.Boolean( + string="Takeaway", + help="Allow customers to order for takeaway", + ) + self_order_kiosk_alternative_fp_id = fields.Many2one( + 'account.fiscal.position', + string='Alternative Fiscal Position', + help='This is useful for restaurants with onsite and take-away services that imply specific tax rates.', + ) + self_order_kiosk_mode = fields.Selection( + [("counter", "Pickup Counter"), ("table", "Service at Table")], + string="Kiosk Mode", + default="counter", + help="Choose the kiosk mode", + required=True, + ) + self_order_kiosk_available_language_ids = fields.Many2many( + "res.lang", + string="Available Languages", + help="Languages available for the kiosk mode", + default=_self_order_kiosk_default_languages, + ) + self_order_kiosk_default_language = fields.Many2one( + "res.lang", + string="Default Language", + help="Default language for the kiosk mode", + default=lambda self: self.env["res.lang"].search( + [("code", "=", self.env.lang)], limit=1 + ), + ) + self_order_kiosk_image_home = fields.Image( + string="Self Order Kiosk Image Home", + help="Image to display on the self order screen", + max_width=1080, + max_height=1920, + default=_self_order_default_image, + ) + self_order_kiosk_image_eat = fields.Image( + string="Self Order Kiosk Image Eat", + help="Image to display on the self order screen", + max_width=1080, + max_height=1920, + default=_self_order_default_image, + ) + self_order_kiosk_image_brand = fields.Image( + string="Self Order Kiosk Image Brand", + help="Image to display on the self order screen", + max_width=1200, + max_height=250, + ) + self_order_kiosk_image_home_name = fields.Char( + string="Self Order Kiosk Image Home Name", + help="Name of the image to display on the self order screen", + default=_self_order_default_image_name, + ) + self_order_kiosk_image_eat_name = fields.Char( + string="Self Order Kiosk Image Eat Name", + help="Name of the image to display on the self order screen", + default=_self_order_default_image_name, + ) + self_order_kiosk_image_brand_name = fields.Char( + string="Self Order Kiosk Image Brand Name", + help="Name of the image to display on the self order screen", + ) self_order_view_mode = fields.Boolean( string="QR Code Menu", help="Allow customers to view the menu on their phones by scanning the QR code on the table", @@ -68,6 +146,13 @@ class PosConfig(models.Model): self.access_token = self._get_access_token() self.floor_ids.table_ids._update_identifier() + @api.onchange("self_order_kiosk") + def _self_order_kiosk_change(self): + for record in self: + if record.self_order_kiosk: + record.self_order_view_mode = False + record.self_order_table_mode = False + @api.model def _init_access_token(self): pos_config_ids = self.env["pos.config"].search([]) @@ -101,6 +186,40 @@ class PosConfig(models.Model): record.self_order_view_mode = False record.self_order_table_mode = False + def _get_qr_code_data(self): + self.ensure_one() + + table_qr_code = [] + if self.self_order_table_mode: + table_qr_code.extend([{ + 'name': floor.name, + 'type': 'table', + 'tables': [ + { + 'identifier': table.identifier, + 'id': table.id, + 'name': table.name, + 'url': self._get_self_order_url(table.id), + } + for table in floor.table_ids.filtered("active") + ] + } + for floor in self.floor_ids] + ) + + # Here we use "range" to determine the number of QR codes to generate from + # this list, which will then be inserted into a PDF. + table_qr_code.extend([{ + 'name': 'Generic', + 'type': 'default', + 'tables': [{ + 'id': i, + 'url': self._get_self_order_url(), + } for i in range(0, 11)] + }]) + + return table_qr_code + def _get_self_order_route(self, table_id: Optional[int] = None) -> str: self.ensure_one() base_route = f"/menu/{self.id}" @@ -155,10 +274,7 @@ class PosConfig(models.Model): ) ) - def _get_available_products(self) -> ProductProduct: - """ - This function returns the products that are available in the given PosConfig - """ + def _get_available_products(self): self.ensure_one() return ( self.env["product.product"] @@ -175,47 +291,144 @@ class PosConfig(models.Model): ) ) + def _get_available_categories(self): + self.ensure_one() + return ( + self.env["pos.category"] + .search( + [ + *( + self.limit_categories + and self.iface_available_categ_ids + and [("id", "in", self.iface_available_categ_ids.ids)] + or [] + ), + ], + order="sequence", + ) + ) + def _get_self_order_data(self) -> Dict: self.ensure_one() return { "pos_config_id": self.id, + "iface_start_categ_id": self.iface_start_categ_id.id, "company_name": self.company_id.name, + "company_color": self.company_id.color, "currency_id": self.currency_id.id, "show_prices_with_tax_included": self.iface_tax_included == "total", "custom_links": self._get_self_order_custom_links(), "products": self._get_available_products()._get_self_order_data(self), - "pos_category": self.env['pos.category'].search_read(fields=["name", "sequence"], order="sequence"), + "combos": self._get_combos_data(), + "pos_category": self._get_available_categories().read(["name", "sequence", "has_image"]), "has_active_session": self.has_active_session, } - def _get_qr_code_data(self): + def _get_combos_data(self): + self.ensure_one() + combos = self.env["pos.combo"].search([]) + + return[{ + 'id': combo.id, + 'name': combo.name, + 'combo_line_ids': combo.combo_line_ids.read(['product_id', 'price', 'lst_price', 'combo_id']) + } for combo in combos] + + def _get_self_order_mobile_data(self): + self.ensure_one() + return { + **self._get_self_order_data(), + "mobile_image_home": self._get_kiosk_image(self.self_order_image), + } + + def _get_self_order_kiosk_data(self): + self.ensure_one() + payment_search_params = self.current_session_id._loader_params_pos_payment_method() + payment_methods = self.payment_method_ids.filtered(lambda p: p.use_payment_terminal == 'adyen').read(payment_search_params['search_params']['fields']) + default_language = self.self_order_kiosk_default_language.read(["code", "name", "iso_code", "flag_image_url"]) + + return { + **self._get_self_order_data(), + "avaiable_payment_methods": self.payment_method_ids.ids, + "pos_payment_methods": payment_methods, + "pos_session": self.current_session_id.read(["id", "access_token"])[0] if self.current_session_id else [], + "kiosk_mode": self.self_order_kiosk_mode, + "kiosk_takeaway": self.self_order_kiosk_takeaway, + "kiosk_alternative_fp": self.self_order_kiosk_alternative_fp_id.id, + "kiosk_image_home": self._get_kiosk_image(self.self_order_kiosk_image_home), + "kiosk_image_eat": self._get_kiosk_image(self.self_order_kiosk_image_eat), + "kiosk_image_brand": self._get_kiosk_image(self.self_order_kiosk_image_brand), + "kiosk_default_language": default_language[0] if default_language else [], + "kiosk_available_languages": self.self_order_kiosk_available_language_ids.read(["code", "display_name", "iso_code", "flag_image_url"]), + } + + def _get_kiosk_image(self, image): + image = Image.open(io.BytesIO(base64.b64decode(image))) if image else False + return image_to_base64(image, 'PNG').decode('utf-8') if image else False + + def _split_qr_codes_list(self, floors: List[Dict], cols: int) -> List[Dict]: + """ + :floors: the list of floors + :cols: the number of qr codes per row + """ + self.ensure_one() + return [ + { + "name": floor.get("name"), + "rows_of_tables": list(split_every(cols, floor["tables"], list)), + } + for floor in floors + ] + + def _compute_self_order_kiosk_url(self): + for record in self: + domain = self.get_base_url() + record.self_order_kiosk_url = '%s/kiosk/%d?access_token=%s' % (domain, record.id, record.access_token) + + def action_close_kiosk_session(self): + current_session_id = self.current_session_id + + if current_session_id: + if current_session_id.order_ids: + current_session_id.order_ids.filtered(lambda o: o.state not in ['paid', 'invoiced']).unlink() + + self.env['bus.bus']._sendone(f'pos_config-{self.access_token}', 'status', { + 'status': 'closed', + }) + + return current_session_id.action_pos_session_closing_control() + + def _compute_status(self): + for record in self: + record.status = 'active' if record.has_active_session else 'inactive' + + def action_open_kiosk(self): self.ensure_one() - table_qr_code = [] - if self.self_order_table_mode: - table_qr_code.extend([{ - 'name': floor.name, - 'type': 'table', - 'tables': [ - { - 'identifier': table.identifier, - 'id': table.id, - 'name': table.name, - 'url': self._get_self_order_url(table.id), - } - for table in floor.table_ids.filtered("active") - ] - } - for floor in self.floor_ids] - ) + return { + 'name': _('Self Kiosk'), + 'type': 'ir.actions.act_url', + 'url': self.self_order_kiosk_url, + } - table_qr_code.extend([{ - 'name': 'Generic', - 'type': 'default', - 'tables': [{ - 'id': i, - 'url': self._get_self_order_url(), - } for i in range(0, 11)] - }]) + def action_open_wizard(self): + self.ensure_one() - return table_qr_code + if not self.current_session_id: + pos_session = self.env['pos.session'].create({'user_id': self.env.uid, 'config_id': self.id}) + pos_session._ensure_access_token() + self.env['bus.bus']._sendone(f'pos_config-{self.access_token}', 'status', { + 'status': 'open', + 'pos_session': pos_session.read(['id', 'access_token'])[0], + }) + + return { + 'name': _('Self Kiosk'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'pos.config', + 'res_id': self.id, + 'target': 'new', + 'views': [(self.env.ref('pos_self_order.pos_self_order_kiosk_read_only_form_dialog').id, 'form')], + } diff --git a/addons/pos_self_order/models/pos_order.py b/addons/pos_self_order/models/pos_order.py index 2d76dda0a44..d0aed71c332 100644 --- a/addons/pos_self_order/models/pos_order.py +++ b/addons/pos_self_order/models/pos_order.py @@ -11,6 +11,29 @@ class PosOrderLine(models.Model): # For the moment we need this to keep attributes consistency between the server and client_side. selected_attributes = fields.Json(string="Selected Attributes") + combo_parent_id = fields.Many2one('pos.order.line', string='Combo Parent') + combo_line_ids = fields.One2many('pos.order.line', 'combo_parent_id', string='Combo Lines') + combo_id = fields.Many2one('pos.combo', string='Combo line reference') + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if (vals.get('combo_parent_uuid')): + vals.update([ + ('combo_parent_id', self.search([('uuid', '=', vals.get('combo_parent_uuid'))]).id) + ]) + if 'combo_parent_uuid' in vals: + del vals['combo_parent_uuid'] + return super().create(vals_list) + + def write(self, vals): + if (vals.get('combo_parent_uuid')): + vals.update([ + ('combo_parent_id', self.search([('uuid', '=', vals.get('combo_parent_uuid'))]).id) + ]) + if 'combo_parent_uuid' in vals: + del vals['combo_parent_uuid'] + return super().write(vals) # FIXME: uuid already pass in pos and move note in pos_restaurant. def _export_for_ui(self, orderline): @@ -24,6 +47,9 @@ class PosOrderLine(models.Model): class PosOrder(models.Model): _inherit = "pos.order" + tracking_number = fields.Integer(string="Tracking Number") + take_away = fields.Boolean(string="Take Away", default=False) + @api.model def create_from_ui(self, orders, draft=False): orders = super().create_from_ui(orders, draft) @@ -39,6 +65,14 @@ class PosOrder(models.Model): return super().remove_from_ui(server_ids) + def _order_fields(self, ui_order): + fields = super()._order_fields(ui_order) + fields.update({ + 'tracking_number': ui_order.get('tracking_number'), + 'take_away': ui_order.get('take_away'), + }) + return fields + def _send_notification(self, order_ids): for order in order_ids: if order.access_token and order.state != 'draft': @@ -53,8 +87,10 @@ class PosOrder(models.Model): return { "id": self.id, "pos_config_id": self.config_id.id, + "take_away": self.take_away, "pos_reference": self.pos_reference, "access_token": self.access_token, + "tracking_number": self.tracking_number, "state": self.state, "date_order": str(self.date_order), "amount_total": self.amount_total, diff --git a/addons/pos_self_order/models/pos_payment_method.py b/addons/pos_self_order/models/pos_payment_method.py new file mode 100644 index 00000000000..2a619307e39 --- /dev/null +++ b/addons/pos_self_order/models/pos_payment_method.py @@ -0,0 +1,9 @@ +from odoo import models + + +class PosPaymentMethod(models.Model): + _inherit = "pos.payment.method" + + # will be overridden. + def payment_request_from_kiosk(self, order): + pass diff --git a/addons/pos_self_order/models/product_product.py b/addons/pos_self_order/models/product_product.py index 169db5f1ba5..5cf496ce303 100644 --- a/addons/pos_self_order/models/product_product.py +++ b/addons/pos_self_order/models/product_product.py @@ -66,9 +66,6 @@ class ProductProduct(models.Model): def _get_price_info( self, pos_config: PosConfig, price: Optional[float] = None, qty: int = 1 ) -> Dict[str, float]: - """ - Function that returns a dict with the price info of a given product - """ self.ensure_one() # if price == None it means that a price was not passed as a parameter, so we use the product's list price # it could happen that a price was passed, but it was 0; in that case we want to use this 0 as the argument, @@ -77,32 +74,36 @@ class ProductProduct(models.Model): price = pos_config.pricelist_id._get_product_price( self, qty, currency=pos_config.currency_id ) + price_info = pos_config.default_fiscal_position_id.map_tax(self.taxes_id).compute_all( price, pos_config.currency_id, qty, product=self ) + display_price = price_info["total_included"] if pos_config.iface_tax_included == "total" else price_info["total_excluded"] + display_price = self.lst_price if self.combo_ids else display_price + return { - "list_price": price_info["total_included"] - if pos_config.iface_tax_included == "total" - else price_info["total_excluded"], - "price_without_tax": price_info["total_excluded"], - "price_with_tax": price_info["total_included"], + "display_price": display_price, + "lst_price": self.lst_price } - def _get_self_order_data(self, pos_config: PosConfig) -> List[Dict]: - """ - returns the list of products with the necessary info for the self order app - """ - return [ - { - "price_info": product._get_price_info(pos_config), - "has_image": bool(product.image_1920), - "attributes": product._get_attributes(pos_config), - "name": product._get_name(), - "id": product.id, - "description_sale": product.description_sale, - "pos_categ_ids": product.pos_categ_ids.mapped("name") or ["Other"], - "is_pos_groupable": product.uom_id.is_pos_groupable, + def _get_product_for_ui(self, pos_config): + self.ensure_one() + return { + "price_info": self._get_price_info(pos_config), + "has_image": bool(self.image_1920), + "attributes": self._get_attributes(pos_config), + "name": self._get_name(), + "id": self.id, + "description_sale": self.description_sale, + "pos_categ_ids": self.pos_categ_ids.mapped("name") or ["Other"], + "pos_combo_ids": self.combo_ids.mapped("id") or False, + "is_pos_groupable": self.uom_id.is_pos_groupable, + "write_date": self.write_date.timestamp(), } + + def _get_self_order_data(self, pos_config: PosConfig) -> List[Dict]: + return [ + product._get_product_for_ui(pos_config) for product in self ] diff --git a/addons/pos_self_order/models/res_config_settings.py b/addons/pos_self_order/models/res_config_settings.py index 690de0e0ae9..ba8b2c55ade 100644 --- a/addons/pos_self_order/models/res_config_settings.py +++ b/addons/pos_self_order/models/res_config_settings.py @@ -8,6 +8,18 @@ from odoo.tools.misc import split_every class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" + pos_self_order_kiosk = fields.Boolean(related="pos_config_id.self_order_kiosk", readonly=False) + pos_self_order_kiosk_mode = fields.Selection(related="pos_config_id.self_order_kiosk_mode", readonly=False) + pos_self_order_kiosk_takeaway = fields.Boolean(related="pos_config_id.self_order_kiosk_takeaway", readonly=False) + pos_self_order_kiosk_alternative_fp_id = fields.Many2one(related="pos_config_id.self_order_kiosk_alternative_fp_id", readonly=False) + pos_self_order_kiosk_image_home = fields.Image(related="pos_config_id.self_order_kiosk_image_home", readonly=False) + pos_self_order_kiosk_image_eat = fields.Image(related="pos_config_id.self_order_kiosk_image_eat", readonly=False) + pos_self_order_kiosk_image_brand = fields.Image(related="pos_config_id.self_order_kiosk_image_brand", readonly=False) + pos_self_order_kiosk_image_home_name = fields.Char(related="pos_config_id.self_order_kiosk_image_home_name", readonly=False) + pos_self_order_kiosk_image_eat_name = fields.Char(related="pos_config_id.self_order_kiosk_image_eat_name", readonly=False) + pos_self_order_kiosk_image_brand_name = fields.Char(related="pos_config_id.self_order_kiosk_image_brand_name", readonly=False) + pos_self_order_kiosk_available_language_ids = fields.Many2many(related="pos_config_id.self_order_kiosk_available_language_ids", readonly=False) + pos_self_order_kiosk_default_language = fields.Many2one(related="pos_config_id.self_order_kiosk_default_language", readonly=False) pos_self_order_view_mode = fields.Boolean( compute="_compute_pos_module_pos_self_order", store=True, readonly=False ) @@ -33,6 +45,22 @@ class ResConfigSettings(models.TransientModel): compute="_compute_pos_module_pos_self_order", store=True, readonly=False ) + @api.onchange("pos_self_order_kiosk_default_language", "pos_self_order_kiosk_available_language_ids") + def _onchange_pos_self_order_kiosk_default_language(self): + if self.pos_self_order_kiosk_default_language not in self.pos_self_order_kiosk_available_language_ids: + self.pos_self_order_kiosk_available_language_ids = self.pos_self_order_kiosk_available_language_ids + self.pos_self_order_kiosk_default_language + if not self.pos_self_order_kiosk_default_language and self.pos_self_order_kiosk_available_language_ids: + self.pos_self_order_kiosk_default_language = self.pos_self_order_kiosk_available_language_ids[0] + + @api.onchange("pos_self_order_kiosk") + def _self_order_kiosk_change(self): + for record in self: + record.is_kiosk_mode = record.pos_self_order_kiosk + + if record.pos_config_id.self_order_kiosk: + record.pos_config_id.self_order_view_mode = False + record.pos_config_id.self_order_table_mode = False + @api.depends("pos_config_id") def _compute_pos_module_pos_self_order(self): for res_config in self: diff --git a/addons/pos_self_order/static/img/eatin.svg b/addons/pos_self_order/static/img/eatin.svg new file mode 100644 index 00000000000..9c1b11294da --- /dev/null +++ b/addons/pos_self_order/static/img/eatin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/pos_self_order/static/img/takeAway.svg b/addons/pos_self_order/static/img/takeAway.svg new file mode 100644 index 00000000000..8f00f7ad026 --- /dev/null +++ b/addons/pos_self_order/static/img/takeAway.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/pos_self_order/static/src/hooks/use_scroll_direction.js b/addons/pos_self_order/static/src/common/hooks/use_scroll_direction.js similarity index 100% rename from addons/pos_self_order/static/src/hooks/use_scroll_direction.js rename to addons/pos_self_order/static/src/common/hooks/use_scroll_direction.js diff --git a/addons/pos_self_order/static/src/common/models/combo.js b/addons/pos_self_order/static/src/common/models/combo.js new file mode 100644 index 00000000000..957ed50045d --- /dev/null +++ b/addons/pos_self_order/static/src/common/models/combo.js @@ -0,0 +1,22 @@ +/** @odoo-module **/ +import { Reactive } from "@web/core/utils/reactive"; +import { ComboLine } from "./combo_line"; + +export class Combo extends Reactive { + constructor({ id, name, combo_line_ids }) { + super(); + this.setup(...arguments); + } + + setup(combo) { + // server only data (recovered after first send to server) + this.id = combo.id || null; + this.name = combo.name || null; + this.combo_line_ids = combo.combo_line_ids || []; + this.initLines(); + } + + initLines() { + this.combo_line_ids = this.combo_line_ids.map((line) => new ComboLine(line)); + } +} diff --git a/addons/pos_self_order/static/src/common/models/combo_line.js b/addons/pos_self_order/static/src/common/models/combo_line.js new file mode 100644 index 00000000000..5d847f84103 --- /dev/null +++ b/addons/pos_self_order/static/src/common/models/combo_line.js @@ -0,0 +1,18 @@ +/** @odoo-module **/ +import { Reactive } from "@web/core/utils/reactive"; + +export class ComboLine extends Reactive { + constructor({ id, lst_price, price, product_id, combo_id }) { + super(); + this.setup(...arguments); + } + + setup(comboLine) { + // server only data (recovered after first send to server) + this.id = comboLine.id || null; + this.lst_price = comboLine.lst_price || null; + this.price = comboLine.price || null; + this.product_id = comboLine.product_id || null; + this.combo_id = comboLine.combo_id || null; + } +} diff --git a/addons/pos_self_order/static/src/models/line.js b/addons/pos_self_order/static/src/common/models/line.js similarity index 61% rename from addons/pos_self_order/static/src/models/line.js rename to addons/pos_self_order/static/src/common/models/line.js index f366a00e30b..fd63ac48ad8 100644 --- a/addons/pos_self_order/static/src/models/line.js +++ b/addons/pos_self_order/static/src/common/models/line.js @@ -10,9 +10,13 @@ export class Line extends Reactive { qty, customer_note, full_product_name, + price_unit, price_subtotal_incl, price_subtotal, selected_attributes, + combo_parent_uuid, + combo_id, + child_lines, }) { super(); this.setup(...arguments); @@ -26,9 +30,13 @@ export class Line extends Reactive { this.product_id = line.product_id; this.qty = line.qty ? line.qty : 0; this.customer_note = line.customer_note; + this.price_unit = line.price_unit || 0; this.price_subtotal_incl = line.price_subtotal_incl || 0; this.price_subtotal = line.price_subtotal || 0; this.selected_attributes = line.selected_attributes || {}; + this.combo_parent_uuid = line.combo_parent_uuid || null; + this.combo_id = line.combo_id || null; + this.child_lines = line.child_lines || []; } isChange(lastChange) { @@ -40,4 +48,21 @@ export class Line extends Reactive { return false; } + + get attributes() { + return Object.entries(this.selected_attributes).map(([key, value]) => { + return { name: key, value }; + }); + } + + get displayPrice() { + // for the moment we use price with tax included but we should look at the config setup + return this.price_subtotal_incl; + } + + updateDataFromServer(data) { + for (const key in data) { + this[key] = data[key]; + } + } } diff --git a/addons/pos_self_order/static/src/models/order.js b/addons/pos_self_order/static/src/common/models/order.js similarity index 73% rename from addons/pos_self_order/static/src/models/order.js rename to addons/pos_self_order/static/src/common/models/order.js index b55f8a0b35b..2a86c494b04 100644 --- a/addons/pos_self_order/static/src/models/order.js +++ b/addons/pos_self_order/static/src/common/models/order.js @@ -13,7 +13,9 @@ export class Order extends Reactive { date, amount_total, amount_tax, + tracking_number, lastChangesSent, + take_away, }) { super(); this.setup(...arguments); @@ -30,6 +32,8 @@ export class Order extends Reactive { this.amount_total = order.amount_total || 0; this.amount_tax = order.amount_tax || 0; this.lines = order.lines || []; + this.tracking_number = order.tracking_number || null; + this.take_away = order.take_away || false; // data this.lastChangesSent = order.lastChangesSent || {}; @@ -45,10 +49,23 @@ export class Order extends Reactive { return this.lines.reduce((acc, line) => (acc += line.qty), 0); } + get isSavedOnServer() { + return this.isAlreadySent && !this.hasNotAllLinesSent(); + } + initLines() { this.lines = this.lines.map((line) => new Line(line)); } + removeLine(lineUuid) { + this.lines = this.lines.filter((line) => line.uuid !== lineUuid); + for (const line of this.lines) { + if (line.combo_parent_uuid === lineUuid) { + this.removeLine(line.uuid); + } + } + } + updateLastChanges() { for (const changeIdx in this.lastChangesSent) { const changeFound = this.lines.find((line) => line.uuid === changeIdx); @@ -78,7 +95,20 @@ export class Order extends Reactive { return lastSend.qty !== line.qty || line.isChange(lastSend); }); } - get isSavedOnServer() { - return this.isAlreadySent && !this.hasNotAllLinesSent(); + + updateDataFromServer(data) { + for (const key in data) { + if (key !== "lines") { + this[key] = data[key]; + } + } + + for (const lines of this.lines) { + const lineFound = data.lines.find((line) => line.uuid === lines.uuid); + + if (lineFound) { + lines.updateDataFromServer(lineFound); + } + } } } diff --git a/addons/pos_self_order/static/src/models/product.js b/addons/pos_self_order/static/src/common/models/product.js similarity index 79% rename from addons/pos_self_order/static/src/models/product.js rename to addons/pos_self_order/static/src/common/models/product.js index bf4cff5e9bc..18dbbe3112b 100644 --- a/addons/pos_self_order/static/src/models/product.js +++ b/addons/pos_self_order/static/src/common/models/product.js @@ -11,7 +11,9 @@ export class Product extends Reactive { id, description_sale, pos_categ_ids, + pos_combo_ids, is_pos_groupable, + write_date, }, showPriceTaxIncluded ) { @@ -28,15 +30,19 @@ export class Product extends Reactive { this.name = product.name; this.description_sale = product.description_sale; this.pos_categ_ids = product.pos_categ_ids; + this.pos_combo_ids = product.pos_combo_ids; this.is_pos_groupable = product.is_pos_groupable; + this.write_date = product.write_date; // data this.showPriceTaxIncluded = showPriceTaxIncluded; } get prices() { - return this.showPriceTaxIncluded - ? this.price_info.price_with_tax - : this.price_info.price_without_tax; + return this.price_info.display_price; + } + + get isCombo() { + return this.pos_combo_ids; } } diff --git a/addons/pos_self_order/static/src/router.js b/addons/pos_self_order/static/src/common/router.js similarity index 96% rename from addons/pos_self_order/static/src/router.js rename to addons/pos_self_order/static/src/common/router.js index 88901bcce30..b27ad367823 100644 --- a/addons/pos_self_order/static/src/router.js +++ b/addons/pos_self_order/static/src/common/router.js @@ -3,7 +3,6 @@ import { Component, onWillRender, useState, xml } from "@odoo/owl"; import { escapeRegExp } from "@web/core/utils/strings"; import { zip } from "@web/core/utils/arrays"; -import { useSelfOrder } from "./self_order_service"; import { useService } from "@web/core/utils/hooks"; function parseParams(matches, paramSpecs) { @@ -27,7 +26,6 @@ export class Router extends Component { static template = xml``; setup() { - this.selfOrder = useSelfOrder(); this.router = useState(useService("router")); this.activeSlot = "default"; this.slotProps = {}; @@ -71,12 +69,14 @@ export class Router extends Component { const match = path.match(regex); if (match) { const parsedParams = parseParams(match.slice(1), paramSpecs); + this.router.activeSlot = routeName; this.activeSlot = routeName; this.slotProps = parsedParams; return; } } + this.router.activeSlot = "default"; this.router.navigate("default"); } } diff --git a/addons/pos_self_order/static/src/self_order_router_service.js b/addons/pos_self_order/static/src/common/self_order_router_service.js similarity index 96% rename from addons/pos_self_order/static/src/self_order_router_service.js rename to addons/pos_self_order/static/src/common/self_order_router_service.js index b0c36f221a7..17c1c974df3 100644 --- a/addons/pos_self_order/static/src/self_order_router_service.js +++ b/addons/pos_self_order/static/src/common/self_order_router_service.js @@ -16,7 +16,7 @@ export class SelfOrderRouter extends Reactive { this.path = window.location.pathname; this.registeredRoutes = {}; this.historyPage = ""; - + this.activeSlot = null; window.addEventListener("popstate", (event) => { this.path = window.location.pathname; }); @@ -24,7 +24,7 @@ export class SelfOrderRouter extends Reactive { addTableIdentifier(table) { const url = new URL(browser.location.href); - url.searchParams.append("table_identifier", table.identifier); + url.searchParams.set("table_identifier", table.identifier); history.replaceState({}, "", url); } diff --git a/addons/pos_self_order/static/src/common/self_order_service.js b/addons/pos_self_order/static/src/common/self_order_service.js new file mode 100644 index 00000000000..c432a43fb75 --- /dev/null +++ b/addons/pos_self_order/static/src/common/self_order_service.js @@ -0,0 +1,110 @@ +/** @odoo-module */ +import { Reactive } from "@web/core/utils/reactive"; +import { ConnectionLostError, RPCError } from "@web/core/network/rpc_service"; +import { _t } from "@web/core/l10n/translation"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { Product } from "@pos_self_order/common/models/product"; +import { session } from "@web/session"; +import { getColor } from "@web/core/colors/colors"; +import { categorySorter } from "@pos_self_order/common/utils"; + +export class selfOrderCommon extends Reactive { + async setup(env, rpc, notification, router, bus_service, cookie) { + // services + this.env = env; + this.router = router; + this.rpc = rpc; + this.notification = notification; + this.bus_service = bus_service; + this.cookie = cookie; + + // data + Object.assign(this, { + ...session.pos_self_order_data, + }); + this.color = getColor(this.company_color); + this.priceLoading = false; + this.productByIds = {}; + this.productsGroupedByCategory = {}; + this.currentProduct = 0; + this.lastEditedProductId = null; + this.initData(); + this.categoryList = new Set( + this.pos_category + .sort((a, b) => a.sequence - b.sequence) + .filter((c) => this.productsGroupedByCategory[c.name]) + .sort((a, b) => categorySorter(a, b, this.iface_start_categ_id)) + ); + } + + initData() { + this.products = this.products.map((p) => { + const product = new Product(p, this.show_prices_with_tax_included); + this.productByIds[product.id] = product; + return product; + }); + + this.productsGroupedByCategory = this.products.reduce((acc, product) => { + product.pos_categ_ids.map((pos_categ_ids) => { + acc[pos_categ_ids] = acc[pos_categ_ids] || []; + acc[pos_categ_ids].push(product); + }); + return acc; + }, {}); + } + + async getPricesFromServer() { + try { + if (!this.currentOrder) { + return; + } + + this.priceLoading = true; + const taxes = await this.rpc(`/pos-self-order/get-orders-taxes/`, { + order: this.currentOrder, + access_token: this.access_token, + }); + + this.currentOrder.updateDataFromServer(taxes); + } catch (error) { + this.handleErrorNotification(error); + } finally { + this.priceLoading = false; + } + } + + handleErrorNotification(error, accessToken = []) { + let message = _t("An error has occurred"); + let cleanOrders = false; + + if (error instanceof RPCError) { + if (error.data.name === "werkzeug.exceptions.Unauthorized") { + message = _t("You're not authorized to perform this action"); + cleanOrders = true; + } else if (error.data.name === "werkzeug.exceptions.NotFound") { + message = _t("Orders not found on server"); + cleanOrders = true; + } + } else if (error instanceof ConnectionLostError) { + message = _t("Connection lost, please try again later"); + } + + this.notification.add(message, { + type: "danger", + }); + + if (accessToken && cleanOrders) { + this.editedOrder = null; + + for (const index in this.orders) { + if (accessToken.includes(this.orders[index].access_token)) { + this.orders.splice(index, 1); + } + } + } + } + + formatMonetary(price) { + return formatMonetary(price, { currencyId: this.currency_id }); + } +} diff --git a/addons/pos_self_order/static/src/common/utils.js b/addons/pos_self_order/static/src/common/utils.js new file mode 100644 index 00000000000..83753e6ae27 --- /dev/null +++ b/addons/pos_self_order/static/src/common/utils.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +export const categorySorter = (a, b, start_categ_id) => { + if (a.id === start_categ_id && b.id !== start_categ_id) { + return -1; // 'a' should come before 'b' + } + if (a.id !== start_categ_id && b.id === start_categ_id) { + return 1; // 'b' should come before 'a' + } + if (a.sequence !== b.sequence) { + return a.sequence - b.sequence; // sort by sequence + } + return a.id - b.id; // sort by id if sequences are the same +}; diff --git a/addons/pos_self_order/static/src/kiosk/bootstrap_overridden.scss b/addons/pos_self_order/static/src/kiosk/bootstrap_overridden.scss new file mode 100644 index 00000000000..a1732d096e1 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/bootstrap_overridden.scss @@ -0,0 +1,2 @@ +$border-radius: .65rem !default; +$border-radius-lg: .85rem !default; diff --git a/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.js b/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.js new file mode 100644 index 00000000000..1977890f851 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.js @@ -0,0 +1,67 @@ +/** @odoo-module */ + +import { Component, useState } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; + +export class AttributeSelection extends Component { + static template = "pos_self_order.AttributeSelection"; + static props = ["product", "productState"]; + + setup() { + this.selfOrder = useselfOrder(); + this.numberOfAttributes = this.props.product.attributes.length; + this.currentAttribute = 0; + + this.state = useState({ + currentAttribute: 0, + editMode: false, + showResume: false, + }); + } + + get attribute() { + return this.props.product.attributes[this.state.currentAttribute]; + } + + next() { + if (this.state.currentAttribute !== this.numberOfAttributes - 1) { + this.state.currentAttribute++; + } else { + this.state.showResume = true; + this.props.productState.showQtyButtons = true; + } + } + + prev() { + if (this.state.currentAttribute !== 0) { + this.state.currentAttribute--; + } + } + + attributeClicked() { + if (this.state.editMode) { + this.state.editMode = false; + this.state.showResume = true; + this.props.productState.showQtyButtons = true; + return; + } + this.next(); + } + + editAttribute(attributeName) { + const index = this.props.product.attributes.findIndex((a) => a.name === attributeName); + this.state.showResume = false; + this.state.editMode = true; + this.state.currentAttribute = index; + this.props.productState.showQtyButtons = false; + } + + get attributeSelected() { + return Object.entries(this.props.productState.selectedVariants).map(([key, value]) => { + return { + name: key, + value: value, + }; + }); + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.scss b/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.scss new file mode 100644 index 00000000000..06a2c36daa1 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.scss @@ -0,0 +1,17 @@ +.o_kiosk-attribute_selection { + + .attribute-option { + height: 250px; + aspect-ratio: 1/1; + + &.active { + border: 1px solid #714B67; + } + } +} + +.attribute-selection-content { + .o_kiosk-card { + aspect-ratio: 1/1; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.xml b/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.xml new file mode 100644 index 00000000000..588fca48bae --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/attribute_selection/attribute_selection.xml @@ -0,0 +1,57 @@ + + + +
+
+ +

Choose your

+
+ +

Your resume

+
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+
+
+

+ +

+ +
+
+
+
+
+ + diff --git a/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.js b/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.js new file mode 100644 index 00000000000..b951667b6b7 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; + +export class CancelPopup extends Component { + static template = "pos_self_order.CancelPopup"; + + confirm() { + this.props.close(); + this.props.confirm(); + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.scss b/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.scss new file mode 100644 index 00000000000..3ed795a95cd --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.scss @@ -0,0 +1,6 @@ +.o_kiosk-cancel_popup { + .modal-dialog { + height: auto !important; + top: 40%; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.xml b/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.xml new file mode 100644 index 00000000000..558ffc5b316 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/cancel_popup/cancel_popup.xml @@ -0,0 +1,23 @@ + + + +
+ +
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/components/combo_selection/combo_selection.js b/addons/pos_self_order/static/src/kiosk/components/combo_selection/combo_selection.js new file mode 100644 index 00000000000..2b4a04cb653 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/combo_selection/combo_selection.js @@ -0,0 +1,24 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { AttributeSelection } from "@pos_self_order/kiosk/components/attribute_selection/attribute_selection"; + +export class ComboSelection extends Component { + static template = "pos_self_order.ComboSelection"; + static props = ["combo", "comboState", "next"]; + static components = { AttributeSelection }; + + setup() { + this.selfOrder = useselfOrder(); + } + + productClicked(lineId) { + const comboLine = this.props.combo.combo_line_ids.find((line) => line.id == lineId); + const productSelected = this.selfOrder.productByIds[comboLine.product_id[0]]; + this.props.comboState.selectedProduct = productSelected; + if (productSelected.attributes.length === 0) { + this.props.next(); + } + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/combo_selection/combo_selection.xml b/addons/pos_self_order/static/src/kiosk/components/combo_selection/combo_selection.xml new file mode 100644 index 00000000000..346e6f65f86 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/combo_selection/combo_selection.xml @@ -0,0 +1,43 @@ + + + +
+ +
+

Choose your

+
+
+
+ +
+
+ + Product image +
+

+ + + + +

+
+
+
+
+
+
+ + + +
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.js b/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.js new file mode 100644 index 00000000000..77411704703 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.js @@ -0,0 +1,27 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; + +export class LanguagePopup extends Component { + static template = "pos_self_order.LanguagePopup"; + + setup() { + this.selfOrder = useselfOrder(); + this.cookie = useService("cookie"); + } + + get languages() { + return this.selfOrder.kiosk_available_languages; + } + + get currentLanguage() { + return this.selfOrder.currentLanguage; + } + + onClickLanguage(language) { + this.cookie.setCookie("frontend_lang", language.code); + window.location.reload(); + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.scss b/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.scss new file mode 100644 index 00000000000..c9d49fba1c8 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.scss @@ -0,0 +1,10 @@ +.o_kiosk-language_popup { + .modal-dialog { + top: 40%; + } + + img { + max-width: 64px; + aspect-ratio: 4/3; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.xml b/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.xml new file mode 100644 index 00000000000..8911de244ea --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/language_popup/language_popup.xml @@ -0,0 +1,28 @@ + + + +
+ +
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.js b/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.js new file mode 100644 index 00000000000..cc7678c87c5 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.js @@ -0,0 +1,104 @@ +/** @odoo-module */ + +import { Component, useRef } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService, useForwardRefToParent } from "@web/core/utils/hooks"; +import { Line } from "@pos_self_order/common/models/line"; + +export class ProductCard extends Component { + static template = "pos_self_order.ProductCard"; + static props = ["product", "currentProductCard?"]; + + selfRef = useRef("selfProductCard"); + currentProductCardRef = useRef("currentProductCard"); + + setup() { + this.selfOrder = useselfOrder(); + this.router = useService("router"); + + useForwardRefToParent("currentProductCard"); + } + + flyToCart() { + const productCardEl = this.selfRef.el; + if (!productCardEl) return; + + const cartButton = document.querySelector(".cart"); + if (!cartButton || window.getComputedStyle(cartButton).display === 'none') { + return; + } + + const clonedCard = productCardEl.cloneNode(true); + const cardRect = productCardEl.getBoundingClientRect(); + const cartRect = cartButton.getBoundingClientRect(); + + clonedCard.style.position = "absolute"; + clonedCard.style.top = `${cardRect.top}px`; + clonedCard.style.left = `${cardRect.left}px`; + clonedCard.style.width = `${cardRect.width}px`; + clonedCard.style.height = `${cardRect.height}px`; + clonedCard.style.opacity = ".85"; + clonedCard.style.transition = "top 1s cubic-bezier(0.95, 0.1, 0.25, 0.95), left 1s cubic-bezier(0.95, 0.1, 0.25, 0.95), width 1s cubic-bezier(0.95, 0.1, 0.25, 0.95), height 1s cubic-bezier(0.95, 0.1, 0.25, 0.95), opacity 0.6s 0.2s ease-in-out"; + + + document.body.appendChild(clonedCard); + + requestAnimationFrame(() => { + clonedCard.style.top = `${cartRect.top}px`; + clonedCard.style.left = `${cartRect.left * 1.25}px`; + clonedCard.style.width = `${cardRect.width * 0.80}px`; + clonedCard.style.height = `${cardRect.height * 0.80}px`; + clonedCard.style.opacity = "0"; // Fading out the card + }); + + clonedCard.addEventListener('transitionend', () => { + clonedCard.remove(); + }); + } + + scaleUpPrice() { + const priceElement = document.querySelector(".total-price"); + console.log(priceElement); + + if (!priceElement) return; + + priceElement.classList.add('scale-up'); + + setTimeout(() => { + priceElement.classList.remove('scale-up'); + }, 600); + } + + async selectProduct() { + const product = this.props.product; + + if (product.isCombo) { + this.router.navigate("combo", { id: product.id }); + } else if (product.attributes.length > 0) { + this.router.navigate("product", { id: product.id }); + } else { + this.flyToCart(); + this.scaleUpPrice(); + const isProductInCart = this.selfOrder.currentOrder.lines.find( + (line) => line.product_id === product.id + ); + + if (isProductInCart) { + isProductInCart.qty++; + } else { + const lines = this.selfOrder.currentOrder.lines; + + lines.push( + new Line({ + id: null, + uuid: null, + qty: 1, + product_id: product.id, + full_product_name: product.name, + }) + ); + } + await this.selfOrder.getPricesFromServer(); + } + } +} diff --git a/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.scss b/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.scss new file mode 100644 index 00000000000..cf542437a00 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.scss @@ -0,0 +1,12 @@ +.o_kiosk_product_card { + + .o_self_order_item_card_image { + object-fit: contain; + aspect-ratio:1/1; + } +} + +.scale-up { + transform: scale(1.2); + transition: transform .75s ease-in-out; +} diff --git a/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.xml b/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.xml new file mode 100644 index 00000000000..f503d912afa --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/components/product_card/product_card.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/addons/pos_self_order/static/src/kiosk/pages/closed/closed.js b/addons/pos_self_order/static/src/kiosk/pages/closed/closed.js new file mode 100644 index 00000000000..ed74198922d --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/closed/closed.js @@ -0,0 +1,9 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; + +export class Closed extends Component { + static template = "pos_self_order.Closed"; + static components = { KioskTemplate }; +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/closed/closed.xml b/addons/pos_self_order/static/src/kiosk/pages/closed/closed.xml new file mode 100644 index 00000000000..e1bc5904462 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/closed/closed.xml @@ -0,0 +1,12 @@ + + + + + +
+ The kiosk is closed +
+
+
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/pages/combo/combo.js b/addons/pos_self_order/static/src/kiosk/pages/combo/combo.js new file mode 100644 index 00000000000..3fd234e967b --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/combo/combo.js @@ -0,0 +1,142 @@ +/** @odoo-module */ + +import { Component, useState } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; +import { ComboSelection } from "@pos_self_order/kiosk/components/combo_selection/combo_selection"; +import { useService } from "@web/core/utils/hooks"; +import { Line } from "@pos_self_order/common/models/line"; + +export class Combo extends Component { + static template = "pos_self_order.Combo"; + static props = ["product"]; + static components = { KioskTemplate, ComboSelection }; + + setup() { + this.selfOrder = useselfOrder(); + this.selfOrder.lastEditedProductId = this.props.product.id; + this.router = useService("router"); + + if (!this.props.product) { + this.router.navigate("productList"); + return; + } + + this.state = useState({ + currentComboIndex: 0, + selectedCombos: [], + showResume: false, + selectedProduct: null, + selectedVariants: {}, + qty: 1, + showQtyButtons: false, + editMode: false, + }); + } + + get currentComboId() { + return this.props.product.pos_combo_ids[this.state.currentComboIndex]; + } + + get currentCombo() { + return this.selfOrder.comboByIds[this.currentComboId]; + } + + resetState() { + this.state.selectedProduct = null; + this.state.selectedVariants = {}; + this.state.showQtyButtons = false; + } + + next() { + const combo = this.currentCombo; + const index = this.state.selectedCombos.findIndex((c) => c.id === combo.id); + if (index !== -1) { + this.state.selectedCombos[index] = { + id: combo.id, + name: combo.name, + product: { + id: this.state.selectedProduct.id, + name: this.state.selectedProduct.name, + variants: Object.entries(this.state.selectedVariants), + }, + }; + } else { + this.state.selectedCombos.push({ + id: combo.id, + name: combo.name, + product: { + id: this.state.selectedProduct.id, + name: this.state.selectedProduct.name, + variants: Object.entries(this.state.selectedVariants), + }, + }); + } + this.resetState(); + if (this.state.editMode) { + this.state.editMode = false; + this.state.showResume = true; + this.state.showQtyButtons = true; + return; + } + this.state.currentComboIndex++; + if (this.state.currentComboIndex == this.props.product.pos_combo_ids.length) { + this.state.showResume = true; + } + } + + back() { + this.router.navigate("product_list"); + } + + changeQuantity(increase) { + if (!increase && this.state.qty === 1) { + return; + } + + return increase ? this.state.qty++ : this.state.qty--; + } + + async addToCart() { + const lines = this.selfOrder.currentOrder.lines; + const parent_line = new Line({ + id: null, + uuid: null, + qty: this.state.qty, + price_unit: this.props.product.price_info.price_without_tax, + product_id: this.props.product.id, + full_product_name: this.props.product.name, + selected_attributes: {}, + combo_parent_uuid: null, + combo_id: null, + }); + lines.push(parent_line); + for (const combo of this.state.selectedCombos) { + const child_line = new Line({ + id: null, + uuid: null, + qty: this.state.qty, + product_id: combo.product.id, + full_product_name: combo.product.name, + selected_attributes: Object.fromEntries(combo.product.variants), + combo_parent_uuid: parent_line.uuid, + combo_id: combo.id, + }); + lines.push(child_line); + parent_line.child_lines.push(child_line); + } + + await this.selfOrder.getPricesFromServer(); + + this.router.back(); + } + + editCombo(combo_id) { + this.state.currentComboIndex = this.state.selectedCombos.findIndex( + (c) => c.id === combo_id + ); + this.state.showResume = false; + this.state.editMode = true; + this.state.showQtyButtons = false; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/combo/combo.scss b/addons/pos_self_order/static/src/kiosk/pages/combo/combo.scss new file mode 100644 index 00000000000..b0c7efccd9d --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/combo/combo.scss @@ -0,0 +1,56 @@ +.breadcrumb { + max-height: 99px; + + div:first-child { + margin-left: -15px; + } + + div:last-child { + padding-right: 15px; + > span:after, > span:before { + border: none; + } + } + + div > span { + padding: 1% 0 1% 60px; + padding-right: 20px; + + &:after, + &:before { + content: " "; + border-top: 50px solid transparent; + border-bottom: 50px solid transparent; + position: absolute; + top: 50%; + margin-top: -50px; + margin-left: -1px; + } + + &:after { + border-left: 30px solid $o-gray-200; + left: 100%; + z-index: 2; + } + + &:before { + border-left: 30px solid $o-gray-300; + margin-left: 1px; + left: 100%; + z-index: 1; + } + } + + div.active-step span { + &:after { + border-left: 30px solid $o-gray-100; + } + } +} + +.o_kiosk-combo { + .o_self_order_item_card_image { + aspect-ratio: 1/1; + object-fit: contain; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/combo/combo.xml b/addons/pos_self_order/static/src/kiosk/pages/combo/combo.xml new file mode 100644 index 00000000000..b3cda416336 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/combo/combo.xml @@ -0,0 +1,95 @@ + + + + + +
+
+ +
+
+ +
+ +
+
+
+ Product image +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+

Your Selection

+
+
+
+
+ + Product image +
+

+
+ - : +
+
+ +
+
+
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+ + + +
+
+ + + diff --git a/addons/pos_self_order/static/src/kiosk/pages/eating_location/eating_location.js b/addons/pos_self_order/static/src/kiosk/pages/eating_location/eating_location.js new file mode 100644 index 00000000000..488d71e5032 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/eating_location/eating_location.js @@ -0,0 +1,27 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; + +export class EatingLocation extends Component { + static template = "pos_self_order.EatingLocation"; + static components = { KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.router = useService("router"); + } + + back() { + this.router.navigate("default"); + } + + selectLocation(loc) { + if (loc === "out") { + this.selfOrder.currentOrder.take_away = true; + } + this.router.navigate("product_list"); + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/eating_location/eating_location.xml b/addons/pos_self_order/static/src/kiosk/pages/eating_location/eating_location.xml new file mode 100644 index 00000000000..8a0f4c16210 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/eating_location/eating_location.xml @@ -0,0 +1,36 @@ + + + + + +
+
+

Choose your eating location

+
+ + +
+
+
+
+ +
+ +
+
+
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.js b/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.js new file mode 100644 index 00000000000..32f063d4fb6 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.js @@ -0,0 +1,44 @@ +/** @odoo-module */ + +import { Component, onWillStart } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; +import { LanguagePopup } from "@pos_self_order/kiosk/components/language_popup/language_popup"; +import { Order } from "@pos_self_order/common/models/order"; + +export class LandingPage extends Component { + static template = "pos_self_order.LandingPage"; + static components = { KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.router = useService("router"); + this.dialog = useService("dialog"); + + onWillStart(() => { + this.selfOrder.currentOrder = new Order({}); + this.tablePadNumber = null; + }); + } + + start() { + if (this.selfOrder.kiosk_takeaway) { + this.router.navigate("location"); + } else { + this.router.navigate("product_list"); + } + } + + openLanguages() { + this.dialog.add(LanguagePopup); + } + + get currentLanguage() { + return this.selfOrder.currentLanguage; + } + + get languages() { + return this.selfOrder.kiosk_available_languages; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.scss b/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.scss new file mode 100644 index 00000000000..a98b9906ede --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.scss @@ -0,0 +1,11 @@ +.o_kiosk_language_selector { + z-index: 100; + + img { + height: 2em; + aspect-ratio: 3/2; + } +} +.o_kiosk-landing_image { + object-fit: cover; +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.xml b/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.xml new file mode 100644 index 00000000000..c5cf166ec4c --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/landing_page/landing_page.xml @@ -0,0 +1,26 @@ + + + + + +
+ + +
+
+ + +
+
+ + + +
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.js b/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.js new file mode 100644 index 00000000000..242e72eca2c --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.js @@ -0,0 +1,72 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; + +export class OrderCart extends Component { + static template = "pos_self_order.OrderCart"; + static components = { KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.router = useService("router"); + } + + get lines() { + const lines = this.selfOrder.currentOrder.lines; + return lines ? lines : []; + } + + backToMenu() { + this.router.navigate("product_list"); + } + + pay() { + if (this.selfOrder.kiosk_mode === "table" && !this.selfOrder.currentOrder.take_away) { + this.router.navigate("stand_number"); + } else { + this.router.navigate("payment"); + } + } + + async removeLine(lineUuid) { + this.selfOrder.currentOrder.removeLine(lineUuid); + await this.selfOrder.getPricesFromServer(); + } + + getChildLines(line) { + return this.lines.filter((l) => l.combo_parent_uuid === line.uuid); + } + + getPrice(line) { + const childLines = this.getChildLines(line); + if (childLines.length == 0) { + return line.price_subtotal_incl; + } else { + let price = 0; + for (const child of childLines) { + price += child.price_subtotal_incl; + } + return price; + } + } + + async changeQuantity(line, increase, fetch = true) { + if (!increase && line.qty === 1) { + this.removeLine(line.uuid); + return; + } + increase ? line.qty++ : line.qty--; + for (const cline of this.selfOrder.currentOrder.lines) { + if (cline.combo_parent_uuid === line.uuid) { + this.changeQuantity(cline, increase, false); + } + } + if (fetch) { + await this.selfOrder.getPricesFromServer(); + } + return; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.scss b/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.scss new file mode 100644 index 00000000000..367ae947664 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.scss @@ -0,0 +1,38 @@ +.o_kiosk-order-cart { + .o_kiosk_product_card { + height: 100% + } + .order-lines { + height: 150px; + } + .o_self_order_item_card_image { + object-fit: contain; + aspect-ratio:1/1; + min-width: 128px; + } + + .product-wrapper { + min-height: 128px; + } +} + + +.upsale-product .o_kiosk_product_card { + height: auto; +} + +.upsale-product .o_self_order_item_card_image { + height: 320px; +} + +@media (max-height: 1280px) { + .upsale-product .o_self_order_item_card_image { + height: 120px; + } +} + +@media (max-height: 1024px) { + .upsale-product .o_self_order_item_card_image { + height: 60px; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.xml b/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.xml new file mode 100644 index 00000000000..2407b6c3cbe --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/order_cart/order_cart.xml @@ -0,0 +1,77 @@ + + + + + +
+

Verify your Order

+
+
+ +
+
+ +
+ + + Product image +
+
+
+
+ +
+ - () +
+
+ - : +
+
+
+
+ - : +
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+
+

Total:

+

Taxes:

+
+
+
+ + +
+ + +
+
+ + + diff --git a/addons/pos_self_order/static/src/kiosk/pages/payment/payment.js b/addons/pos_self_order/static/src/kiosk/pages/payment/payment.js new file mode 100644 index 00000000000..fc3573e3271 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/payment/payment.js @@ -0,0 +1,65 @@ +/** @odoo-module */ + +import { Component, onWillStart, onWillUnmount, useState } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; + +export class Payment extends Component { + static template = "pos_self_order.Payment"; + static components = { KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.selfOrder.isOrder(); + this.router = useService("router"); + this.rpc = useService("rpc"); + this.state = useState({ + selection: true, + paymentMethodId: null, + }); + + onWillUnmount(() => { + this.selfOrder.paymentError = false; + }); + + onWillStart(async () => { + if (this.selfOrder.pos_payment_methods.length === 0) { + const order = await this.rpc("/pos-self-order/process-new-order/kiosk", { + order: this.selfOrder.currentOrder, + access_token: this.selfOrder.access_token, + table_identifier: null, + }); + this.selfOrder.updateOrderFromServer(order); + this.router.navigate("payment_success"); + } else if (this.selfOrder.pos_payment_methods.length === 1) { + this.selectMethod(this.selfOrder.pos_payment_methods[0].id); + } + }); + } + + selectMethod(methodId) { + this.state.selection = false; + this.state.paymentMethodId = methodId; + this.startPayment(); + } + + async startPayment() { + this.selfOrder.paymentError = false; + try { + const order = await this.rpc(`/kiosk/payment/${this.selfOrder.pos_config_id}/kiosk`, { + order: this.selfOrder.currentOrder, + access_token: this.selfOrder.access_token, + payment_method_id: this.state.paymentMethodId, + }); + this.selfOrder.updateOrderFromServer(order); + } catch (error) { + this.selfOrder.handleErrorNotification(error); + this.selfOrder.paymentError = true; + } + } + + get showFooterBtn() { + return this.selfOrder.paymentError || this.state.selection; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/payment/payment.xml b/addons/pos_self_order/static/src/kiosk/pages/payment/payment.xml new file mode 100644 index 00000000000..7f5d8ffac5e --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/payment/payment.xml @@ -0,0 +1,42 @@ + + + + + +
+
+
+ +
+
+ +
+
+
+
+
+ +

Follow instructions
on the terminal

+ +
+ +

The payment has failed
please retry

+ +
+
+
+
+ +
+ + +
+
+
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.js b/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.js new file mode 100644 index 00000000000..5e7f484d176 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.js @@ -0,0 +1,47 @@ +/** @odoo-module */ + +import { Component, onMounted, useState } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; + +export class PaymentSuccess extends Component { + static template = "pos_self_order.PaymentSuccess"; + static components = { KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.selfOrder.isOrder(); + this.router = useService("router"); + this.cookie = useService("cookie"); + this.state = useState({ + onReload: false, + }); + + onMounted(() => { + setTimeout(() => { + this.setDefautLanguage(); + }, 5000); + }); + } + + backToHome() { + if (!this.setDefautLanguage()) { + this.router.navigate("default"); + } + } + + setDefautLanguage() { + if ( + this.selfOrder.currentLanguage.code !== this.selfOrder.kiosk_default_language.code && + !this.state.onReload + ) { + this.cookie.setCookie("frontend_lang", this.selfOrder.kiosk_default_language.code); + window.location.reload(); + this.state.onReload = true; + return true; + } + + return this.state.onReload; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.scss b/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.scss new file mode 100644 index 00000000000..9ad6f3117ce --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.scss @@ -0,0 +1,6 @@ +.number { + font-size: 6rem; +} +.o_kiosk-success_loader { + z-index: 10; +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.xml b/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.xml new file mode 100644 index 00000000000..07d3f65cf2b --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/payment_success/payment_success.xml @@ -0,0 +1,38 @@ + + + + + +
+
+
+ Loading... +
+
+
+
+ +
+

We're preparing
your order!

+
+ + Your order number + + + + Your table number + +

Order number:

+
+
+

Get your ticket below

+
+
+ +
+ +
+
+
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/pages/product/product.js b/addons/pos_self_order/static/src/kiosk/pages/product/product.js new file mode 100644 index 00000000000..93d61dc74c2 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/product/product.js @@ -0,0 +1,90 @@ +/** @odoo-module */ + +import { Component, useState } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { AttributeSelection } from "@pos_self_order/kiosk/components/attribute_selection/attribute_selection"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; +import { Line } from "@pos_self_order/common/models/line"; +import { useService } from "@web/core/utils/hooks"; + +export class Product extends Component { + static template = "pos_self_order.Product"; + static props = ["product", "back?", "onValidate?"]; + static components = { AttributeSelection, KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.router = useService("router"); + + if (!this.props.product) { + this.router.navigate("productList"); + return; + } + + this.selfOrder.lastEditedProductId = this.props.product.id; + this.state = useState({ + qty: 1, + customer_note: "", + selectedVariants: {}, + showQtyButtons: false, + product: this.props.product, + }); + } + + get product() { + return this.state.product; + } + + back() { + this.router.navigate("product_list"); + } + + changeQuantity(increase) { + const currentQty = this.state.qty; + + if (!increase && currentQty === 1) { + return; + } + + return increase ? this.state.qty++ : this.state.qty--; + } + + get fullProductName() { + const productAttributeString = Object.values(this.state.selectedVariants).join(", "); + let name = `${this.product.name}`; + + if (productAttributeString) { + name += ` (${productAttributeString})`; + } + + return name; + } + + async addToCart() { + const lines = this.selfOrder.currentOrder.lines; + + lines.push( + new Line({ + id: null, + uuid: null, + qty: this.state.qty, + product_id: this.product.id, + full_product_name: this.fullProductName, + customer_note: this.state.customer_note, + selected_attributes: this.state.selectedVariants, + }) + ); + + // If a command line does not have a quantity greater than 0, we consider it deleted + await this.selfOrder.getPricesFromServer(); + this.router.back(); + } + + get attributeSelection() { + if (this.product.attributes.length === 0) { + this.state.showQtyButtons = true; + } + + return this.product.attributes.length > 0; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/product/product.xml b/addons/pos_self_order/static/src/kiosk/pages/product/product.xml new file mode 100644 index 00000000000..3cfab084edb --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/product/product.xml @@ -0,0 +1,56 @@ + + + + + + + +
+
+ Product image +
+

+ + +

+
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ + +
+ + +
+
+ + + diff --git a/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.js b/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.js new file mode 100644 index 00000000000..8aafe13d4d6 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.js @@ -0,0 +1,58 @@ +/** @odoo-module */ + +import { Component, onMounted, useRef } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { ProductCard } from "@pos_self_order/kiosk/components/product_card/product_card"; +import { CancelPopup } from "@pos_self_order/kiosk/components/cancel_popup/cancel_popup"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; +import { useService, useChildRef } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +export class ProductList extends Component { + static template = "pos_self_order.ProductList"; + static components = { ProductCard, KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.dialog = useService("dialog"); + this.router = useService("router"); + this.productsList = useRef("productsList"); + this.currentProductCard = useChildRef(); + + onMounted(() => { + if (this.selfOrder.lastEditedProductId) { + this.scrollTo(this.currentProductCard, { behavior: "instant" }); + } + }); + } + + categoryClicked(categoryName) { + this.selfOrder.currentCategory = categoryName; + } + + get productCategory() { + const productByCat = this.selfOrder.productsGroupedByCategory; + const currentCategory = this.selfOrder.currentCategory; + return productByCat[currentCategory] ? productByCat[currentCategory] : []; + } + + scrollTo(ref = null, { behavior = "smooth" } = {}) { + this.productsList.el.scroll({ + top: ref?.el ? ref.el.offsetTop - this.productsList.el.offsetTop : 0, + behavior, + }); + } + + review() { + this.router.navigate("cart"); + } + + cancelOrder() { + this.dialog.add(CancelPopup, { + title: _t("Cancel order"), + confirm: () => { + this.router.navigate("default"); + }, + }); + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.scss b/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.scss new file mode 100644 index 00000000000..9b356f7bcf5 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.scss @@ -0,0 +1,28 @@ +.o_kiosk_product_list { + .category-list { + height: 225px; + overflow-y: hidden; + + .category-item { + width: 150px; + height: 150px; + } + + img { + object-fit: cover; + } + } + + .product-list { + overflow-y: scroll; + height: calc(100% - 225px); + + .product-list-category { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + + h2:first-of-type { + padding-top: 2rem !important; + } + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.xml b/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.xml new file mode 100644 index 00000000000..0e3b9f0fb12 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/product_list/product_list.xml @@ -0,0 +1,54 @@ + + + + + + +
+ + +
+
+ +
+
+
+

+
+ + + +
+

+
+
+
+ + +
+ + +
+
+ + + diff --git a/addons/pos_self_order/static/src/kiosk/pages/stand_number/stand_number.js b/addons/pos_self_order/static/src/kiosk/pages/stand_number/stand_number.js new file mode 100644 index 00000000000..455b76851f3 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/stand_number/stand_number.js @@ -0,0 +1,43 @@ +/** @odoo-module */ + +import { Component, useState } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; +import { KioskTemplate } from "@pos_self_order/kiosk/template/kiosk_template"; + +export class StandNumber extends Component { + static template = "pos_self_order.StandNumber"; + static components = { KioskTemplate }; + + setup() { + this.selfOrder = useselfOrder(); + this.selfOrder.isOrder(); + this.router = useService("router"); + this.state = useState({ + standNumber: "", + }); + } + + numberClick(event) { + const key = event.target.attributes.data.value; + + if (key === "reset") { + this.state.standNumber = this.state.standNumber.slice(0, -1); + } else if (key === "clear") { + this.state.standNumber = ""; + } else { + this.state.standNumber += key; + } + } + + confirm() { + if (this.state.standNumber.length > 0) { + this.selfOrder.tablePadNumber = this.state.standNumber; + this.router.navigate("payment"); + } + } + + get tableInput() { + return this.state.standNumber ? this.state.standNumber : "_ _"; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/pages/stand_number/stand_number.xml b/addons/pos_self_order/static/src/kiosk/pages/stand_number/stand_number.xml new file mode 100644 index 00000000000..f7b33269ee4 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/pages/stand_number/stand_number.xml @@ -0,0 +1,43 @@ + + + + + +
+
+
+

Pick a plate and write your number here

+
+ +
+
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
+ +
+
0
+
+ +
+
+
+
+
+ +
+ + +
+
+
+
+
diff --git a/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.js b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.js new file mode 100644 index 00000000000..81daf1f5bcd --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.js @@ -0,0 +1,58 @@ +/** @odoo-module */ +import { Component, whenReady, App } from "@odoo/owl"; +import { makeEnv, startServices } from "@web/env"; +import { setLoadXmlDefaultApp, templates } from "@web/core/assets"; +import { _t } from "@web/core/l10n/translation"; +import { LandingPage } from "@pos_self_order/kiosk/pages/landing_page/landing_page"; +import { EatingLocation } from "@pos_self_order/kiosk/pages/eating_location/eating_location"; +import { Product } from "@pos_self_order/kiosk/pages/product/product"; +import { ProductList } from "@pos_self_order/kiosk/pages/product_list/product_list"; +import { OrderCart } from "@pos_self_order/kiosk/pages/order_cart/order_cart"; +import { Closed } from "@pos_self_order/kiosk/pages/closed/closed"; +import { Payment } from "@pos_self_order/kiosk/pages/payment/payment"; +import { Combo } from "@pos_self_order/kiosk/pages/combo/combo"; +import { PaymentSuccess } from "@pos_self_order/kiosk/pages/payment_success/payment_success"; +import { StandNumber } from "@pos_self_order/kiosk/pages/stand_number/stand_number"; +import { MainComponentsContainer } from "@web/core/main_components_container"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { Router } from "@pos_self_order/common/router"; + +class selfOrderIndex extends Component { + static template = "pos_self_order.selfOrderIndex"; + static props = []; + static components = { + Payment, + EatingLocation, + ProductList, + OrderCart, + PaymentSuccess, + Closed, + Combo, + StandNumber, + Product, + LandingPage, + Router, + MainComponentsContainer, + }; + + setup() { + this.selfOrder = useselfOrder(); + } +} + +export async function createPublicRoot() { + await whenReady(); + const env = makeEnv(); + await startServices(env); + const app = new App(selfOrderIndex, { + templates, + env: env, + dev: env.debug, + translateFn: _t, + translatableAttributes: ["data-tooltip"], + }); + setLoadXmlDefaultApp(app); + return app.mount(document.body); +} +createPublicRoot(); +export default { selfOrderIndex, createPublicRoot }; diff --git a/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.scss b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.scss new file mode 100644 index 00000000000..0d686885379 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.scss @@ -0,0 +1,26 @@ +html, +body { + width: 100%; + height: 100vh; + margin: 0; + padding: 0; + box-sizing: border-box; + overflow: hidden; + font-size: 24px; + -webkit-user-select: none !important; + -ms-user-select: none !important; + user-select: none !important; +} + +.h-10 { + height: 10%; +} + +.h-90 { + height: 90%; +} + +.o_kiosk-card img { + object-fit: contain; + aspect-ratio: 1/1; +} diff --git a/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.xml b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.xml new file mode 100644 index 00000000000..aa4d706c1a1 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_index.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/pos_self_order/static/src/kiosk/self_order_kiosk_service.js b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_service.js new file mode 100644 index 00000000000..612926d7a79 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/self_order_kiosk_service.js @@ -0,0 +1,108 @@ +/** @odoo-module */ +import { useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Order } from "@pos_self_order/common/models/order"; +import { Combo } from "@pos_self_order/common/models/combo"; +import { selfOrderCommon } from "@pos_self_order/common/self_order_service"; + +export class selfOrder extends selfOrderCommon { + constructor(...args) { + super(...args); + this.ready = this.setup(...args).then(() => this); + } + + async setup(...args) { + this.comboByIds = {}; + + await super.setup(...args); + + this.bus_service.addChannel(`pos_config-${this.access_token}`); + this.bus_service.addEventListener("notification", ({ detail }) => { + for (const message of detail) { + this.dispatchMessage(message); + } + }); + + this.currentLanguage = this.kiosk_available_languages.find( + (l) => l.code === this.cookie.current.frontend_lang + ); + + if (this.kiosk_default_language && !this.currentLanguage) { + this.currentLanguage = this.kiosk_default_language; + } + + this.cookie.setCookie("frontend_lang", this.currentLanguage.code); + + this.currentCategory = this.pos_category.length > 0 ? this.pos_category[0].name : null; + // required information to send an order + this.paymentError = false; + this.tablePadNumber = null; + this.currentOrder = new Order({}); + this.eatingLocation = "in"; // (in, out) in by default because out can be disabled in the config + } + + dispatchMessage(message) { + const payload = message.payload; + const type = message.type; + + if (type === "status") { + if (payload.status === "closed") { + this.pos_session = []; + } else { + // reload to get potential new settings + // more easier than RPC for now + window.location.reload(); + } + this.isSession(); + } else if (type === "payment_status") { + if (payload.payment_result === "Success") { + this.updateOrderFromServer(payload.order); + this.router.navigate("payment_success"); + } else { + this.paymentError = true; + } + } + } + + updateOrderFromServer(order) { + this.currentOrder.updateDataFromServer(order); + } + + initData() { + super.initData(); + + this.combos = this.combos.map((c) => { + const combo = new Combo(c); + this.comboByIds[combo.id] = combo; + return combo; + }); + } + + isSession() { + if (!this.pos_session || !this.pos_session.id) { + this.router.navigate("closed"); + } else if (this.router.activeSlot === "closed") { + this.router.navigate("default"); + } + } + + isOrder() { + if (!this.currentOrder || !this.currentOrder.lines.length) { + this.router.navigate("default"); + } + } +} + +export const selfOrderService = { + dependencies: ["rpc", "notification", "router", "bus_service", "cookie"], + async start(env, { rpc, notification, router, bus_service, cookie }) { + return new selfOrder(env, rpc, notification, router, bus_service, cookie).ready; + }, +}; + +registry.category("services").add("self_order_kiosk", selfOrderService); + +export function useselfOrder() { + return useState(useService("self_order_kiosk")); +} diff --git a/addons/pos_self_order/static/src/kiosk/template/kiosk_template.js b/addons/pos_self_order/static/src/kiosk/template/kiosk_template.js new file mode 100644 index 00000000000..f26f8ed65a1 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/template/kiosk_template.js @@ -0,0 +1,54 @@ +/** @odoo-module */ + +import { Component, markup, onMounted } from "@odoo/owl"; +import { useselfOrder } from "@pos_self_order/kiosk/self_order_kiosk_service"; +import { useService } from "@web/core/utils/hooks"; +import { escape } from "@web/core/utils/strings"; + +export class KioskTemplate extends Component { + static template = "pos_self_order.KioskTemplate"; + static props = ["headerHeight", "contentHeight", "footerHeight", "slots"]; + + setup() { + this.selfOrder = useselfOrder(); + this.selfOrder.isSession(); + this.router = useService("router"); + this.idleTimer = null; + + onMounted(() => { + const style = document.createElement("style"); + const color = escape(this.selfOrder.color); + style.innerHTML = markup(`.btn-primary,.text-bg-primary,.bg-primary { + background-color: ${color} !important; + border-color: ${color} !important; + } .border-primary { + border-color: ${color} !important; + }`); + + document.getElementsByTagName("head")[0].appendChild(style); + window.addEventListener("touchstart", () => this.idleDetector()); + window.addEventListener("mousedown", () => this.idleDetector()); + window.addEventListener("click", () => this.idleDetector()); + window.addEventListener("load", () => this.idleDetector()); + + // disable right click, on your mobile (tactil kiosk), + // you can right-click while holding down the key + document.addEventListener("contextmenu", (event) => { + event.preventDefault(); + }); + }); + } + + get isHeaderBanner() { + return this.selfOrder.kiosk_image_home; + } + + idleDetector() { + clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => this.router.navigate("default"), 3 * 60 * 1000); // 5 minutes + } + + get showShadow() { + return this.router.activeSlot === "default" ? false : true; + } +} diff --git a/addons/pos_self_order/static/src/kiosk/template/kiosk_template.scss b/addons/pos_self_order/static/src/kiosk/template/kiosk_template.scss new file mode 100644 index 00000000000..7eace8d75c8 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/template/kiosk_template.scss @@ -0,0 +1,13 @@ +.o_kiosk-template { + .header-section { + .header-image { + background-position: center; + background-size: cover; + } + } + .content-section { + } + .o_footer_shadow { + box-shadow: 0 -0.125rem 0.25rem rgba(0, 0, 0, 0.075); + } +} diff --git a/addons/pos_self_order/static/src/kiosk/template/kiosk_template.xml b/addons/pos_self_order/static/src/kiosk/template/kiosk_template.xml new file mode 100644 index 00000000000..774ca39dca5 --- /dev/null +++ b/addons/pos_self_order/static/src/kiosk/template/kiosk_template.xml @@ -0,0 +1,16 @@ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
diff --git a/addons/pos_self_order/static/src/components/lines/lines.js b/addons/pos_self_order/static/src/mobile/components/lines/lines.js similarity index 92% rename from addons/pos_self_order/static/src/components/lines/lines.js rename to addons/pos_self_order/static/src/mobile/components/lines/lines.js index c2d296c5de8..393a99a6522 100644 --- a/addons/pos_self_order/static/src/components/lines/lines.js +++ b/addons/pos_self_order/static/src/mobile/components/lines/lines.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { Component } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { _t } from "@web/core/l10n/translation"; import { extractProductNameAndAttributes } from "../../utils"; import { useService } from "@web/core/utils/hooks"; diff --git a/addons/pos_self_order/static/src/components/lines/lines.xml b/addons/pos_self_order/static/src/mobile/components/lines/lines.xml similarity index 100% rename from addons/pos_self_order/static/src/components/lines/lines.xml rename to addons/pos_self_order/static/src/mobile/components/lines/lines.xml diff --git a/addons/pos_self_order/static/src/components/navbar/navbar.js b/addons/pos_self_order/static/src/mobile/components/navbar/navbar.js similarity index 89% rename from addons/pos_self_order/static/src/components/navbar/navbar.js rename to addons/pos_self_order/static/src/mobile/components/navbar/navbar.js index 873daf5b40c..bd437685f25 100644 --- a/addons/pos_self_order/static/src/components/navbar/navbar.js +++ b/addons/pos_self_order/static/src/mobile/components/navbar/navbar.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { Component } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { useService } from "@web/core/utils/hooks"; export class NavBar extends Component { static template = "pos_self_order.NavBar"; diff --git a/addons/pos_self_order/static/src/components/navbar/navbar.scss b/addons/pos_self_order/static/src/mobile/components/navbar/navbar.scss similarity index 100% rename from addons/pos_self_order/static/src/components/navbar/navbar.scss rename to addons/pos_self_order/static/src/mobile/components/navbar/navbar.scss diff --git a/addons/pos_self_order/static/src/components/navbar/navbar.xml b/addons/pos_self_order/static/src/mobile/components/navbar/navbar.xml similarity index 100% rename from addons/pos_self_order/static/src/components/navbar/navbar.xml rename to addons/pos_self_order/static/src/mobile/components/navbar/navbar.xml diff --git a/addons/pos_self_order/static/src/components/popup_table/popup_table.js b/addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.js similarity index 94% rename from addons/pos_self_order/static/src/components/popup_table/popup_table.js rename to addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.js index 976fa7d546c..5c2ffc786cf 100644 --- a/addons/pos_self_order/static/src/components/popup_table/popup_table.js +++ b/addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { Component, onWillStart, useState } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { useService } from "@web/core/utils/hooks"; import { groupBy } from "@web/core/utils/arrays"; diff --git a/addons/pos_self_order/static/src/components/popup_table/popup_table.scss b/addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.scss similarity index 100% rename from addons/pos_self_order/static/src/components/popup_table/popup_table.scss rename to addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.scss diff --git a/addons/pos_self_order/static/src/components/popup_table/popup_table.xml b/addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.xml similarity index 100% rename from addons/pos_self_order/static/src/components/popup_table/popup_table.xml rename to addons/pos_self_order/static/src/mobile/components/popup_table/popup_table.xml diff --git a/addons/pos_self_order/static/src/components/price_details/price_details.js b/addons/pos_self_order/static/src/mobile/components/price_details/price_details.js similarity index 77% rename from addons/pos_self_order/static/src/components/price_details/price_details.js rename to addons/pos_self_order/static/src/mobile/components/price_details/price_details.js index 38e4808db5b..2185534ee10 100644 --- a/addons/pos_self_order/static/src/components/price_details/price_details.js +++ b/addons/pos_self_order/static/src/mobile/components/price_details/price_details.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { Component } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; export class PriceDetails extends Component { static template = "pos_self_order.PriceDetails"; diff --git a/addons/pos_self_order/static/src/components/price_details/price_details.xml b/addons/pos_self_order/static/src/mobile/components/price_details/price_details.xml similarity index 100% rename from addons/pos_self_order/static/src/components/price_details/price_details.xml rename to addons/pos_self_order/static/src/mobile/components/price_details/price_details.xml diff --git a/addons/pos_self_order/static/src/components/product_card/product_card.js b/addons/pos_self_order/static/src/mobile/components/product_card/product_card.js similarity index 93% rename from addons/pos_self_order/static/src/components/product_card/product_card.js rename to addons/pos_self_order/static/src/mobile/components/product_card/product_card.js index 908f0cf6807..c33b819ca81 100644 --- a/addons/pos_self_order/static/src/components/product_card/product_card.js +++ b/addons/pos_self_order/static/src/mobile/components/product_card/product_card.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { Component } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { useForwardRefToParent, useService } from "@web/core/utils/hooks"; export class ProductCard extends Component { diff --git a/addons/pos_self_order/static/src/components/product_card/product_card.scss b/addons/pos_self_order/static/src/mobile/components/product_card/product_card.scss similarity index 95% rename from addons/pos_self_order/static/src/components/product_card/product_card.scss rename to addons/pos_self_order/static/src/mobile/components/product_card/product_card.scss index b0bc5977b4c..1e70a46b752 100644 --- a/addons/pos_self_order/static/src/components/product_card/product_card.scss +++ b/addons/pos_self_order/static/src/mobile/components/product_card/product_card.scss @@ -11,10 +11,7 @@ min-width: 90px; min-height: 90px; height: 90px !important; - - & img { - object-fit: cover; - } + object-fit: cover; } .o_self_order_item_card { border-right: 1px solid $border-color; diff --git a/addons/pos_self_order/static/src/components/product_card/product_card.xml b/addons/pos_self_order/static/src/mobile/components/product_card/product_card.xml similarity index 96% rename from addons/pos_self_order/static/src/components/product_card/product_card.xml rename to addons/pos_self_order/static/src/mobile/components/product_card/product_card.xml index 1c4cdf7c0fc..31dcc95b0b5 100644 --- a/addons/pos_self_order/static/src/components/product_card/product_card.xml +++ b/addons/pos_self_order/static/src/mobile/components/product_card/product_card.xml @@ -24,7 +24,7 @@
Product image diff --git a/addons/pos_self_order/static/src/hooks/use_detection.js b/addons/pos_self_order/static/src/mobile/hooks/use_detection.js similarity index 87% rename from addons/pos_self_order/static/src/hooks/use_detection.js rename to addons/pos_self_order/static/src/mobile/hooks/use_detection.js index ec1344aaab0..2f3cc14cdfc 100644 --- a/addons/pos_self_order/static/src/hooks/use_detection.js +++ b/addons/pos_self_order/static/src/mobile/hooks/use_detection.js @@ -13,7 +13,7 @@ import { useEffect, useState } from "@odoo/owl"; export function useDetection(root, targets, getDeps) { const detected = useState({ name: "" }); useEffect((stop) => { - if (stop) { + if (stop || !root) { return; } /** @type{IntersectionObserverEntry[]} */ @@ -31,11 +31,7 @@ export function useDetection(root, targets, getDeps) { delete allVisible[name]; } - if (Object.keys(allVisible).length > 0) { - selectedName = Object.entries(allVisible).sort((a, b) => { - return b[1] - a[1]; - })[0][0]; - } + selectedName = Object.keys(allVisible).reverse()[0]; }); if (selectedName) { diff --git a/addons/pos_self_order/static/src/pages/landing_page/landing_page.js b/addons/pos_self_order/static/src/mobile/pages/landing_page/landing_page.js similarity index 89% rename from addons/pos_self_order/static/src/pages/landing_page/landing_page.js rename to addons/pos_self_order/static/src/mobile/pages/landing_page/landing_page.js index 286dff3e579..eb48d0c3e13 100644 --- a/addons/pos_self_order/static/src/pages/landing_page/landing_page.js +++ b/addons/pos_self_order/static/src/mobile/pages/landing_page/landing_page.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { Component } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { useService } from "@web/core/utils/hooks"; import { formatMonetary } from "@web/views/fields/formatters"; export class LandingPage extends Component { diff --git a/addons/pos_self_order/static/src/pages/landing_page/landing_page.xml b/addons/pos_self_order/static/src/mobile/pages/landing_page/landing_page.xml similarity index 97% rename from addons/pos_self_order/static/src/pages/landing_page/landing_page.xml rename to addons/pos_self_order/static/src/mobile/pages/landing_page/landing_page.xml index 48ab31e7589..0a108db83d3 100644 --- a/addons/pos_self_order/static/src/pages/landing_page/landing_page.xml +++ b/addons/pos_self_order/static/src/mobile/pages/landing_page/landing_page.xml @@ -3,7 +3,7 @@
diff --git a/addons/pos_self_order/static/src/pages/order_cart/order_cart.js b/addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.js similarity index 80% rename from addons/pos_self_order/static/src/pages/order_cart/order_cart.js rename to addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.js index cd3661bd934..c9a384a839c 100644 --- a/addons/pos_self_order/static/src/pages/order_cart/order_cart.js +++ b/addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.js @@ -1,12 +1,12 @@ /** @odoo-module */ import { Component, onWillStart, useState } from "@odoo/owl"; -import { NavBar } from "@pos_self_order/components/navbar/navbar"; -import { ProductCard } from "@pos_self_order/components/product_card/product_card"; -import { Lines } from "@pos_self_order/components/lines/lines"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; -import { PriceDetails } from "@pos_self_order/components/price_details/price_details"; -import { PopupTable } from "@pos_self_order/components/popup_table/popup_table"; +import { NavBar } from "@pos_self_order/mobile/components/navbar/navbar"; +import { ProductCard } from "@pos_self_order/mobile/components/product_card/product_card"; +import { Lines } from "@pos_self_order/mobile/components/lines/lines"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; +import { PriceDetails } from "@pos_self_order/mobile/components/price_details/price_details"; +import { PopupTable } from "@pos_self_order/mobile/components/popup_table/popup_table"; import { _t } from "@web/core/l10n/translation"; import { useService } from "@web/core/utils/hooks"; diff --git a/addons/pos_self_order/static/src/pages/order_cart/order_cart.scss b/addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.scss similarity index 100% rename from addons/pos_self_order/static/src/pages/order_cart/order_cart.scss rename to addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.scss diff --git a/addons/pos_self_order/static/src/pages/order_cart/order_cart.xml b/addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.xml similarity index 100% rename from addons/pos_self_order/static/src/pages/order_cart/order_cart.xml rename to addons/pos_self_order/static/src/mobile/pages/order_cart/order_cart.xml diff --git a/addons/pos_self_order/static/src/pages/orders_history/orders_history.js b/addons/pos_self_order/static/src/mobile/pages/orders_history/orders_history.js similarity index 64% rename from addons/pos_self_order/static/src/pages/orders_history/orders_history.js rename to addons/pos_self_order/static/src/mobile/pages/orders_history/orders_history.js index f5df995aaaf..a9898e5a22b 100644 --- a/addons/pos_self_order/static/src/pages/orders_history/orders_history.js +++ b/addons/pos_self_order/static/src/mobile/pages/orders_history/orders_history.js @@ -1,11 +1,11 @@ /** @odoo-module */ import { Component, useState } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; -import { ProductCard } from "@pos_self_order/components/product_card/product_card"; -import { Lines } from "@pos_self_order/components/lines/lines"; -import { PriceDetails } from "@pos_self_order/components/price_details/price_details"; -import { NavBar } from "@pos_self_order/components/navbar/navbar"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; +import { ProductCard } from "@pos_self_order/mobile/components/product_card/product_card"; +import { Lines } from "@pos_self_order/mobile/components/lines/lines"; +import { PriceDetails } from "@pos_self_order/mobile/components/price_details/price_details"; +import { NavBar } from "@pos_self_order/mobile/components/navbar/navbar"; import { useService } from "@web/core/utils/hooks"; export class OrdersHistory extends Component { @@ -13,14 +13,14 @@ export class OrdersHistory extends Component { static components = { NavBar, ProductCard, Lines, PriceDetails }; static props = []; - setup() { + async setup() { this.selfOrder = useSelfOrder(); this.router = useService("router"); this.state = useState({ loadingProgress: true, }); - this.loadOrder(); + await this.loadOrder(); } async loadOrder() { @@ -29,7 +29,7 @@ export class OrdersHistory extends Component { } get orders() { - return this.selfOrder.orders.filter((o) => o.access_token); + return this.selfOrder.orders.filter((o) => o.access_token).sort((a, b) => b.id - a.id); } editOrder(order) { diff --git a/addons/pos_self_order/static/src/pages/orders_history/orders_history.xml b/addons/pos_self_order/static/src/mobile/pages/orders_history/orders_history.xml similarity index 100% rename from addons/pos_self_order/static/src/pages/orders_history/orders_history.xml rename to addons/pos_self_order/static/src/mobile/pages/orders_history/orders_history.xml diff --git a/addons/pos_self_order/static/src/pages/product_list/product_list.js b/addons/pos_self_order/static/src/mobile/pages/product_list/product_list.js similarity index 84% rename from addons/pos_self_order/static/src/pages/product_list/product_list.js rename to addons/pos_self_order/static/src/mobile/pages/product_list/product_list.js index d0e6098bcc0..b0f36a46ab5 100644 --- a/addons/pos_self_order/static/src/pages/product_list/product_list.js +++ b/addons/pos_self_order/static/src/mobile/pages/product_list/product_list.js @@ -1,13 +1,13 @@ /** @odoo-module */ import { Component, onMounted, useEffect, useRef, useState } from "@odoo/owl"; -import { useSelfOrder } from "@pos_self_order/self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; import { useAutofocus, useChildRef, useService } from "@web/core/utils/hooks"; -import { NavBar } from "@pos_self_order/components/navbar/navbar"; -import { ProductCard } from "@pos_self_order/components/product_card/product_card"; +import { NavBar } from "@pos_self_order/mobile/components/navbar/navbar"; +import { ProductCard } from "@pos_self_order/mobile/components/product_card/product_card"; import { fuzzyLookup } from "@web/core/utils/search"; -import { useScrollDirection } from "@pos_self_order/hooks/use_scroll_direction"; -import { useDetection } from "@pos_self_order/hooks/use_detection"; +import { useScrollDirection } from "@pos_self_order/common/hooks/use_scroll_direction"; +import { useDetection } from "@pos_self_order/mobile/hooks/use_detection"; import { Transition } from "@web/core/transition"; export class ProductList extends Component { @@ -35,13 +35,13 @@ export class ProductList extends Component { this.productGroups = Object.fromEntries( Array.from(this.selfOrder.categoryList).map((category) => { - return [category, useRef(`productsWithCategory_${category}`)]; + return [category.name, useRef(`productsWithCategory_${category.name}`)]; }) ); this.categoryButton = Object.fromEntries( Array.from(this.selfOrder.categoryList).map((category) => { - return [category, useRef(`category_${category}`)]; + return [category.name, useRef(`category_${category.name}`)]; }) ); @@ -51,7 +51,7 @@ export class ProductList extends Component { } }); - // this is used to hide the navbar when the user is scrolling down + // This is used to hide the navbar when the user is scrolling down this.scroll = useScrollDirection(this.productsList); // This detects the product group where we are currently scrolled @@ -120,11 +120,7 @@ export class ProductList extends Component { // where the products with that category are displayed. // We don't have to manually mark the category as selected because the // useDetection hook will do it for us. - this.scrollTo( - this.currentProductGroup.name != category_name - ? this.productGroups[category_name] - : null - ); + this.scrollTo(this.productGroups[category_name]); } focusSearch() { diff --git a/addons/pos_self_order/static/src/pages/product_list/product_list.xml b/addons/pos_self_order/static/src/mobile/pages/product_list/product_list.xml similarity index 93% rename from addons/pos_self_order/static/src/pages/product_list/product_list.xml rename to addons/pos_self_order/static/src/mobile/pages/product_list/product_list.xml index 668ca14dc81..137833b591c 100644 --- a/addons/pos_self_order/static/src/pages/product_list/product_list.xml +++ b/addons/pos_self_order/static/src/mobile/pages/product_list/product_list.xml @@ -21,13 +21,13 @@
@@ -50,10 +50,10 @@

No products found

- -
-

- + +
+

+

+ t-esc="selfOrder.formatMonetary(props.product.price_info.display_price)"/>

this); } - async setup(env, rpc, notification, router) { - Object.assign(this, { - ...session.pos_self_order_data, - }); - - this.env = env; - this.router = router; - this.rpc = rpc; + async setup(...args) { this.orders = []; this.editedOrder = null; - this.productByIds = {}; this.ordering = false; - this.priceLoading = false; - this.currentProduct = 0; - this.lastEditedProductId = null; - this.productsGroupedByCategory = {}; - this.notification = notification; - this.initData(); - this.categoryList = new Set( - this.pos_category - .sort((a, b) => a.sequence - b.sequence) - .map((c) => c.name) - .filter((c) => this.productsGroupedByCategory[c]) - ); + + await super.setup(...args); if (this.self_order_mode !== "qr_code") { effect((state) => this.saveOrderToLocalStorage(state.orders), [this]); } - if (this.has_active_session && this.self_order_mode !== "qr_code") { + if (this.has_active_session && this.self_order_mode !== "qr_code" && this.access_token) { this.ordering = true; } @@ -60,11 +39,7 @@ export class SelfOrder { } initData() { - this.products = this.products.map((p) => { - const product = new Product(p, this.show_prices_with_tax_included); - this.productByIds[product.id] = product; - return product; - }); + super.initData(); if (this.self_order_mode !== "qr_code") { const orders = JSON.parse(localStorage.getItem("orders")) ?? []; @@ -76,14 +51,6 @@ export class SelfOrder { }) ); } - - this.productsGroupedByCategory = this.products.reduce((acc, product) => { - product.pos_categ_ids.map((pos_categ_ids) => { - acc[pos_categ_ids] = acc[pos_categ_ids] || []; - acc[pos_categ_ids].push(product); - }); - return acc; - }, {}); } saveOrderToLocalStorage(orders) { @@ -136,15 +103,11 @@ export class SelfOrder { } } - formatMonetary(price) { - return formatMonetary(price, { currencyId: this.currency_id }); - } - async sendDraftOrderToServer() { try { const rpcUrl = this.currentOrder.isAlreadySent ? "/pos-self-order/update-existing-order" - : "/pos-self-order/process-new-order"; + : "/pos-self-order/process-new-order/self"; const order = await this.rpc(rpcUrl, { order: this.currentOrder, @@ -196,15 +159,11 @@ export class SelfOrder { //FIXME, if the user refresh the page with not sent, we will lost this data. const accessTokensFromServer = orders.map((order) => order.access_token); - for (const idx in this.orders) { - const order = this.orders[idx]; - + for (const order of this.orders) { if (order.access_token) { const orderFromServer = orders.find((o) => o.access_token === order.access_token); - if (orderFromServer) { - this.orders[idx] = Object.assign(this.orders[idx], orderFromServer); - this.orders[idx].lines = orderFromServer.lines.map((l) => new Line(l)); + order.updateDataFromServer(orderFromServer); } } } @@ -219,65 +178,6 @@ export class SelfOrder { } } - async getPricesFromServer() { - this.priceLoading = true; - - try { - if (!this.currentOrder) { - return; - } - - const taxes = await this.rpc(`/pos-self-order/get-orders-taxes/`, { - order: this.currentOrder, - access_token: this.access_token, - }); - - for (const line of this.currentOrder.lines) { - const lineTaxes = taxes.lines.find((ol) => ol.uuid === line.uuid); - line.price_subtotal = lineTaxes.price_subtotal; - line.price_subtotal_incl = lineTaxes.price_subtotal_incl; - } - - this.editedOrder.amount_total = taxes.amount_total; - this.editedOrder.amount_tax = taxes.amount_tax; - } catch (error) { - this.handleErrorNotification(error); - } - - this.priceLoading = false; - } - - handleErrorNotification(error, accessToken = []) { - let message = _t("An error has occurred"); - let cleanOrders = false; - - if (error instanceof RPCError) { - if (error.data.name === "werkzeug.exceptions.Unauthorized") { - message = _t("You're not authorized to perform this action"); - cleanOrders = true; - } else if (error.data.name === "werkzeug.exceptions.NotFound") { - message = _t("Orders wasn't found on the server"); - cleanOrders = true; - } - } else if (error instanceof ConnectionLostError) { - message = _t("Connection lost, please try again later"); - } - - this.notification.add(message, { - type: "danger", - }); - - if (accessToken && cleanOrders) { - this.editedOrder = null; - - for (const index in this.orders) { - if (accessToken.includes(this.orders[index].access_token)) { - this.orders.splice(index, 1); - } - } - } - } - changeOrderState(access_token, state) { const order = this.orders.filter((o) => o.access_token === access_token); let message = _t("Your order status has been changed"); diff --git a/addons/pos_self_order/static/src/self_order_root.js b/addons/pos_self_order/static/src/mobile/self_order_root.js similarity index 72% rename from addons/pos_self_order/static/src/self_order_root.js rename to addons/pos_self_order/static/src/mobile/self_order_root.js index 0969e6a6252..f9b6d3e9862 100644 --- a/addons/pos_self_order/static/src/self_order_root.js +++ b/addons/pos_self_order/static/src/mobile/self_order_root.js @@ -3,15 +3,15 @@ import { Component, whenReady, App } from "@odoo/owl"; import { makeEnv, startServices } from "@web/env"; import { setLoadXmlDefaultApp, templates } from "@web/core/assets"; import { _t } from "@web/core/l10n/translation"; -import { LandingPage } from "@pos_self_order/pages/landing_page/landing_page"; -import { NavBar } from "@pos_self_order/components/navbar/navbar"; -import { ProductMainView } from "@pos_self_order/pages/product_main_view/product_main_view"; -import { OrderCart } from "@pos_self_order/pages/order_cart/order_cart"; -import { ProductList } from "@pos_self_order/pages/product_list/product_list"; -import { OrdersHistory } from "@pos_self_order/pages/orders_history/orders_history"; -import { Router } from "@pos_self_order/router"; +import { LandingPage } from "@pos_self_order/mobile/pages/landing_page/landing_page"; +import { NavBar } from "@pos_self_order/mobile/components/navbar/navbar"; +import { ProductMainView } from "@pos_self_order/mobile/pages/product_main_view/product_main_view"; +import { OrderCart } from "@pos_self_order/mobile/pages/order_cart/order_cart"; +import { ProductList } from "@pos_self_order/mobile/pages/product_list/product_list"; +import { OrdersHistory } from "@pos_self_order/mobile/pages/orders_history/orders_history"; +import { Router } from "@pos_self_order/common/router"; import { MainComponentsContainer } from "@web/core/main_components_container"; -import { useSelfOrder } from "./self_order_service"; +import { useSelfOrder } from "@pos_self_order/mobile/self_order_mobile_service"; class SelfOrderRoot extends Component { static template = "pos_self_order.SelfOrderRoot"; diff --git a/addons/pos_self_order/static/src/self_order_root.xml b/addons/pos_self_order/static/src/mobile/self_order_root.xml similarity index 89% rename from addons/pos_self_order/static/src/self_order_root.xml rename to addons/pos_self_order/static/src/mobile/self_order_root.xml index 9b96698e576..405c2b18ef9 100644 --- a/addons/pos_self_order/static/src/self_order_root.xml +++ b/addons/pos_self_order/static/src/mobile/self_order_root.xml @@ -1,7 +1,7 @@ -
+
The restaurant is currently closed
diff --git a/addons/pos_self_order/static/src/utils.js b/addons/pos_self_order/static/src/mobile/utils.js similarity index 100% rename from addons/pos_self_order/static/src/utils.js rename to addons/pos_self_order/static/src/mobile/utils.js diff --git a/addons/pos_self_order/views/point_of_sale_dashboard.xml b/addons/pos_self_order/views/point_of_sale_dashboard.xml index 8f728f3fddf..a057987f881 100644 --- a/addons/pos_self_order/views/point_of_sale_dashboard.xml +++ b/addons/pos_self_order/views/point_of_sale_dashboard.xml @@ -1,5 +1,41 @@ + + pos.self.order.kiosk.read.only.form.dialog + pos.config + + +
+
From your Kiosk, open this URL:
+ +
+
+ + +
+ +
+
+ + + pos.self.order.search.view + pos.config + + + + + + + + + + Kiosk + pos.config + kanban,list,tree + + + pos.config.kanban.view.inherit.self_order pos.config @@ -10,7 +46,7 @@ position="inside"> + + + +
+ + + +
+
+ + + self_order_kiosk +
-
\ No newline at end of file + diff --git a/addons/pos_self_order/views/pos_config_view.xml b/addons/pos_self_order/views/pos_config_view.xml new file mode 100644 index 00000000000..25318af1d38 --- /dev/null +++ b/addons/pos_self_order/views/pos_config_view.xml @@ -0,0 +1,13 @@ + + + + pos.self.pos.config.tree.view + pos.config + + + + + + + + diff --git a/addons/pos_self_order/views/pos_self_order_kiosk.index.xml b/addons/pos_self_order/views/pos_self_order_kiosk.index.xml new file mode 100644 index 00000000000..254194883b6 --- /dev/null +++ b/addons/pos_self_order/views/pos_self_order_kiosk.index.xml @@ -0,0 +1,25 @@ + + + + diff --git a/addons/pos_self_order/views/index.xml b/addons/pos_self_order/views/pos_self_order_mobile.index.xml similarity index 83% rename from addons/pos_self_order/views/index.xml rename to addons/pos_self_order/views/pos_self_order_mobile.index.xml index 653b244ce9b..7e68441f407 100644 --- a/addons/pos_self_order/views/index.xml +++ b/addons/pos_self_order/views/pos_self_order_mobile.index.xml @@ -1,6 +1,6 @@ -