[ADD] point_of_sale,pos_*: add kiosk mode
*:point_of_sale,pos_adyen,pos_online_payment_self_order,pos_restaurant, pos_sale,pos_self_order,pos_self_order_adyen,pos_self_order_restaurant, pos_self_order_sale This PR adds the kiosk to the existing `pos_self_order` module, which until now has enabled end-users to order their restaurant meals directly from their smartphones. We've added the possibility of creating kiosks / totems (devices with large touch screens) that allow users to order their menu directly without having to go through a waiter. Once the order has been placed, the user will be invited to pay either by adyen, already integrated into this PR, or at the counter. At the end of the order, the user will receive an order number and his table number if `service at table` is activated. Main differences with the Self-order mobile: - Kiosk shouldn't be accessible publicly - Kiosk should support payments via card readers closes odoo/odoo#129562 Design: XLU Dev: ADGU & MODA Taskid: 3323163 Related: odoo/upgrade#5024 Signed-off-by: Adrien Guilliams (adgu) <adgu@odoo.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<form string="Point of Sale Session" create="0" edit="0">
|
||||
<header>
|
||||
<button name="open_frontend_cb" type="object" string="Continue Selling"
|
||||
invisible="rescue or state not in ['opening_control', 'opened']"/>
|
||||
invisible="rescue or state not in ['opening_control', 'opened'] or config_id.self_order_kiosk"/>
|
||||
<button id="validate_closing_control" name="action_pos_session_closing_control" type="object" string="Close Session & Post Entries"
|
||||
invisible="(state != 'closing_control' and not rescue or state == 'closed' and rescue) and state != 'closing_control'"
|
||||
class="oe_highlight"/>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<t groups="point_of_sale.group_pos_manager">
|
||||
<field name="is_kiosk_mode" invisible="1"/>
|
||||
<field name="pos_selectable_categ_ids" invisible="1"/>
|
||||
<field name="pos_has_active_session" invisible="1"/>
|
||||
<field name="pos_allowed_pricelist_ids" invisible="1"/>
|
||||
@@ -30,17 +31,16 @@
|
||||
<p>Please create/select a Point of Sale above to show the configuration options.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_notification_alert alert alert-warning mt-1 mb-0" invisible="not pos_has_active_session" role="alert">
|
||||
A session is currently opened for this PoS. Some settings can only be changed after the session is closed.
|
||||
<button class="btn-link" style="padding:0" name="pos_open_ui" type="object" context="{'pos_config_id': pos_config_id}">Click here to close the session</button>
|
||||
</div>
|
||||
<div class="o_notification_alert alert alert-warning" invisible="pos_company_has_template" role="alert">
|
||||
There is no Chart of Accounts configured on the company. Please go to the invoicing settings to install a Chart of Accounts.
|
||||
</div>
|
||||
<div invisible="not pos_config_id">
|
||||
<block title="PoS Interface" id="pos_interface_section">
|
||||
<block title="Restaurant Mode" id="restaurant_section">
|
||||
<!-- Wrap the warnings in an o_setting_box so that it doesn't show in the search. -->
|
||||
<div class="o_notification_alert alert alert-warning" invisible="not pos_has_active_session" role="alert">
|
||||
A session is currently opened for this PoS. Some settings can only be changed after the session is closed.
|
||||
<button class="btn-link" style="padding:0" name="pos_open_ui" type="object" context="{'pos_config_id': pos_config_id}">Click here to close the session</button>
|
||||
</div>
|
||||
<div class="o_notification_alert alert alert-warning" invisible="pos_company_has_template" role="alert">
|
||||
There is no Chart of Accounts configured on the company. Please go to the invoicing settings to install a Chart of Accounts.
|
||||
</div>
|
||||
<setting>
|
||||
<field name="pos_module_pos_restaurant" readonly="pos_has_active_session"/>
|
||||
<div class="content-group" id="warning_text_pos_restaurant" invisible="not pos_module_pos_restaurant">
|
||||
@@ -49,6 +49,61 @@
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Payment" id="pos_payment_section">
|
||||
<setting id="payment_methods_new" string="Payment Methods" help="Payment methods available">
|
||||
<field name="pos_payment_method_ids" colspan="4" widget="many2many_tags" invisible="pos_has_active_session" required="pos_company_has_template" options="{'no_create': True}" />
|
||||
<div>
|
||||
<button name="%(action_payment_methods_tree)d" icon="oi-arrow-right" type="action" string="Payment Methods" class="btn-link"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Automatically validate order" help="Automatically validates orders paid with a payment terminal.">
|
||||
<field name="pos_auto_validate_terminal_payment"/>
|
||||
</setting>
|
||||
<setting string="Cash Rounding" documentation="/applications/sales/point_of_sale/pricing/cash_rounding.html" help="Define the smallest coinage of the currency used to pay by cash">
|
||||
<field name="pos_cash_rounding"/>
|
||||
<div class="content-group mt16" invisible="not pos_cash_rounding">
|
||||
<div class="row mt16">
|
||||
<label string="Rounding Method" for="pos_rounding_method" class="col-lg-3 o_light_label" />
|
||||
<field name="pos_rounding_method" required="pos_cash_rounding" domain="[('company_id', '=', company_id)]"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<div class="col">
|
||||
<field name="pos_only_round_cash_method"/>
|
||||
<label string="Only on cash methods" for="pos_only_round_cash_method" class="o_light_label" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<button name="%(account.rounding_list_action)d" icon="oi-arrow-right"
|
||||
type="action" string="Cash Roundings" class="btn-link"
|
||||
invisible="not group_cash_rounding"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting help="Set a maximum difference allowed between the expected and counted money during the closing of the session">
|
||||
<field name="pos_set_maximum_difference" />
|
||||
<div class="content-group mt16" invisible="not pos_set_maximum_difference">
|
||||
<label for="pos_amount_authorized_diff" string="Authorized Difference" class="fw-normal"/>
|
||||
<field name="pos_amount_authorized_diff"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting invisible="not pos_cash_control" string="Coins/Bills" help="Set of coins/bills that will be used in opening and closing control">
|
||||
<field name="pos_default_bill_ids" colspan="4" widget="many2many_tags" options="{'no_create_edit': True}" context="{'default_pos_config_ids':[(6, False, [pos_config_id])]}"/>
|
||||
</setting>
|
||||
<setting id="iface_tipproduct" title="This product is used as reference on customer receipts." string="Tips" help="Accept customer tips or convert their change to a tip">
|
||||
<field name="pos_iface_tipproduct" readonly="pos_has_active_session"/>
|
||||
<div class="content-group" invisible="not pos_iface_tipproduct">
|
||||
<div class="mt16" id="tip_product">
|
||||
<label string="Tip Product" for="pos_tip_product_id" class="o_light_label"/>
|
||||
<field name="pos_tip_product_id"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="PoS Interface" id="pos_interface_section">
|
||||
<div class="o_notification_alert alert alert-warning" invisible="pos_company_has_template" role="alert">
|
||||
There is no Chart of Accounts configured on the company. Please go to the invoicing settings to install a Chart of Accounts.
|
||||
</div>
|
||||
<setting help="Start selling from a default product category">
|
||||
<field name="pos_start_category"/>
|
||||
<div class="content-group mt16" invisible="not pos_start_category">
|
||||
@@ -56,7 +111,7 @@
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting title="Employees can scan their badge or enter a PIN to log in to a PoS session. These credentials are configurable in the *HR Settings* tab of the employee form." string="Multi Employees per Session" help="Allow to log and switch between selected Employees">
|
||||
<setting id="multiple_employee_session" title="Employees can scan their badge or enter a PIN to log in to a PoS session. These credentials are configurable in the *HR Settings* tab of the employee form." string="Multi Employees per Session" help="Allow to log and switch between selected Employees">
|
||||
<field name="pos_module_pos_hr" readonly="pos_has_active_session"/>
|
||||
<div class="content-group mt16" invisible="not pos_module_pos_hr">
|
||||
<div class="text-warning" id="warning_text_employees">
|
||||
@@ -76,7 +131,7 @@
|
||||
<setting help="Improve navigation for imprecise industrial touchscreens">
|
||||
<field name="pos_iface_big_scrollbars"/>
|
||||
</setting>
|
||||
<setting string="Margins & Costs" help="Show margins & costs on product information">
|
||||
<setting id="margin_and_cost" string="Margins & Costs" help="Show margins & costs on product information">
|
||||
<field name="pos_is_margins_costs_accessible_to_every_user"/>
|
||||
</setting>
|
||||
<setting string="Share Open Orders" help="Allow to access each other's active orders" id="is_order_shared" invisible="pos_module_pos_restaurant">
|
||||
@@ -91,7 +146,6 @@
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<block title="Accounting" id="pos_accounting_section">
|
||||
<setting id="default_sales_tax_setting" title="This tax is applied to any new product created in the catalog.">
|
||||
<label string="Default Sales Tax" for="sale_tax_id"/>
|
||||
@@ -114,7 +168,7 @@
|
||||
</div>
|
||||
<field name="account_default_pos_receivable_account_id" colspan="4" nolabel="1" domain="[('reconcile', '=', True), ('account_type', '=', 'asset_receivable'), ('company_id', '=', company_id)]"/>
|
||||
</setting>
|
||||
<setting title="Choose a specific fiscal position at the order depending on the kind of customer (tax exempt, onsite vs. takeaway, etc.)." string="Flexible Taxes" help="Use fiscal positions to get different taxes by order">
|
||||
<setting id="flexible_taxes" title="Choose a specific fiscal position at the order depending on the kind of customer (tax exempt, onsite vs. takeaway, etc.)." string="Flexible Taxes" help="Use fiscal positions to get different taxes by order">
|
||||
<field name="pos_tax_regime_selection"/>
|
||||
<div class="content-group mt16" invisible="not pos_tax_regime_selection">
|
||||
<div class="row">
|
||||
@@ -168,7 +222,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting class="price_control" title="Only users with Manager access rights for PoS app can modify the product prices on orders." string="Price Control" help="Restrict price modification to managers">
|
||||
<setting id="price_control" class="price_control" title="Only users with Manager access rights for PoS app can modify the product prices on orders." string="Price Control" help="Restrict price modification to managers">
|
||||
<field name="pos_restrict_price_control"/>
|
||||
</setting>
|
||||
<setting id="product_prices" string="Product Prices" help="Product prices on receipts">
|
||||
@@ -179,7 +233,7 @@
|
||||
target="_blank" class="oe-link"><i class="oi oi-fw oi-arrow-right"/>How to manage tax-included prices</a>
|
||||
</div>
|
||||
</setting>
|
||||
<setting help="Allow cashiers to set a discount per line">
|
||||
<setting id="manual_discount" help="Allow cashiers to set a discount per line">
|
||||
<field name="pos_manual_discount"/>
|
||||
</setting>
|
||||
<setting help="Adds a button to set a global discount">
|
||||
@@ -228,57 +282,6 @@
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<block title="Payment" id="pos_payment_section">
|
||||
<setting id="payment_methods_new" string="Payment Methods" help="Payment methods available">
|
||||
<field name="pos_payment_method_ids" colspan="4" widget="many2many_tags" readonly="pos_has_active_session" required="pos_company_has_template" options="{'no_create': True}" />
|
||||
<div>
|
||||
<button name="%(action_payment_methods_tree)d" icon="oi-arrow-right" type="action" string="Payment Methods" class="btn-link"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Automatically validate order" help="Automatically validates orders paid with a payment terminal.">
|
||||
<field name="pos_auto_validate_terminal_payment"/>
|
||||
</setting>
|
||||
<setting string="Cash Rounding" documentation="/applications/sales/point_of_sale/pricing/cash_rounding.html" help="Define the smallest coinage of the currency used to pay by cash">
|
||||
<field name="pos_cash_rounding"/>
|
||||
<div class="content-group mt16" invisible="not pos_cash_rounding">
|
||||
<div class="row mt16">
|
||||
<label string="Rounding Method" for="pos_rounding_method" class="col-lg-3 o_light_label" />
|
||||
<field name="pos_rounding_method" required="pos_cash_rounding" domain="[('company_id', '=', company_id)]"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<div class="col">
|
||||
<field name="pos_only_round_cash_method"/>
|
||||
<label string="Only on cash methods" for="pos_only_round_cash_method" class="o_light_label" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<button name="%(account.rounding_list_action)d" icon="oi-arrow-right"
|
||||
type="action" string="Cash Roundings" class="btn-link"
|
||||
invisible="not group_cash_rounding"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting help="Set a maximum difference allowed between the expected and counted money during the closing of the session">
|
||||
<field name="pos_set_maximum_difference" />
|
||||
<div class="content-group mt16" invisible="not pos_set_maximum_difference">
|
||||
<label for="pos_amount_authorized_diff" string="Authorized Difference" class="fw-normal"/>
|
||||
<field name="pos_amount_authorized_diff"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting invisible="not pos_cash_control" string="Coins/Bills" help="Set of coins/bills that will be used in opening and closing control">
|
||||
<field name="pos_default_bill_ids" colspan="4" widget="many2many_tags" options="{'no_create_edit': True}" context="{'default_pos_config_ids':[(6, False, [pos_config_id])]}"/>
|
||||
</setting>
|
||||
<setting id="iface_tipproduct" title="This product is used as reference on customer receipts." string="Tips" help="Accept customer tips or convert their change to a tip">
|
||||
<field name="pos_iface_tipproduct" readonly="pos_has_active_session"/>
|
||||
<div class="content-group" invisible="not pos_iface_tipproduct">
|
||||
<div class="mt16" id="tip_product">
|
||||
<label string="Tip Product" for="pos_tip_product_id" class="o_light_label"/>
|
||||
<field name="pos_tip_product_id"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<block title="Payment Terminals" help="Those settings are common to all PoS." id="pos_payment_terminals_section">
|
||||
<setting id="adyen_payment_terminal_setting" title="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method." string="Adyen" help="Accept payments with an Adyen payment terminal"
|
||||
documentation="/applications/sales/point_of_sale/payment_methods/terminals/adyen.html">
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//setting[@id='pos-loyalty']" position="inside">
|
||||
<div class="content-group" invisible="not module_loyalty">
|
||||
<div class="mt16 o_light_label">
|
||||
<div class="mt16 o_light_label" id="pos_gift_card_settings" invisible="is_kiosk_mode">
|
||||
<field name="pos_gift_card_settings" colspan="4" nolabel="1" widget="radio"/>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<button name="%(loyalty.loyalty_program_discount_loyalty_action)d" icon="oi-arrow-right" type="action" string="Discount & Loyalty" class="btn-link"/>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<div class="mt8" id="button_loyalty_program" invisible="is_kiosk_mode">
|
||||
<button name="%(loyalty.loyalty_program_gift_ewallet_action)d" icon="oi-arrow-right" type="action" string="Gift cards & eWallet" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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/**/*',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<record id="res_config_settings_view_form_menu" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.pos_online_payment.view</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="pos_restaurant.res_config_settings_view_form"/>
|
||||
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='pos_self_order_pay_after']" position="after">
|
||||
<div>
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -6,37 +6,35 @@
|
||||
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div id="warning_text_pos_restaurant" position="replace"/>
|
||||
<block id="pos_interface_section" position="after">
|
||||
<block title="Restaurant & Bar" id="restaurant_section" invisible="not pos_module_pos_restaurant">
|
||||
<setting string="Floors & Tables Map" help="Design floors and assign orders to tables" invisible="not pos_module_pos_restaurant">
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<label string="Floors" for="pos_floor_ids" class="o_light_label me-2"/>
|
||||
<field name="pos_floor_ids" widget="many2many_tags" readonly="pos_has_active_session" />
|
||||
</div>
|
||||
<div>
|
||||
<button name="%(pos_restaurant.action_restaurant_floor_form)d" icon="oi-arrow-right" type="action" string="Floors" class="btn-link"/>
|
||||
</div>
|
||||
<block id="restaurant_section" position="inside">
|
||||
<setting id="floor_and_table_map" string="Floors & Tables Map" help="Design floors and assign orders to tables" invisible="is_kiosk_mode or not pos_module_pos_restaurant">
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<label string="Floors" for="pos_floor_ids" class="o_light_label me-2"/>
|
||||
<field name="pos_floor_ids" widget="many2many_tags" readonly="pos_has_active_session" />
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Internal Notes" help="Add internal notes on order lines for the kitchen" id="iface_orderline_notes" invisible="not pos_module_pos_restaurant">
|
||||
<field name="pos_iface_orderline_notes"/>
|
||||
</setting>
|
||||
<setting string="Early Receipt Printing" help="Allow to print receipt before payment" id="iface_printbill" invisible="not pos_module_pos_restaurant">
|
||||
<field name="pos_iface_printbill"/>
|
||||
</setting>
|
||||
<setting help="Split total or order lines" id="iface_splitbill" invisible="not pos_module_pos_restaurant">
|
||||
<field name="pos_iface_splitbill" string="Allow Bill Splitting"/>
|
||||
</setting>
|
||||
<setting help="Online reservation for restaurant" invisible="not pos_module_pos_restaurant">
|
||||
<field name="pos_module_pos_restaurant_appointment" string="Table Booking" widget="upgrade_boolean" />
|
||||
<div class="content-group" id="pos_table_booking" invisible="not pos_module_pos_restaurant_appointment">
|
||||
<div class="text-warning mt16 mb4">
|
||||
Save this page and come back here to set up the feature.
|
||||
</div>
|
||||
<div>
|
||||
<button name="%(pos_restaurant.action_restaurant_floor_form)d" icon="oi-arrow-right" type="action" string="Floors" class="btn-link"/>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Internal Notes" help="Add internal notes on order lines for the kitchen" id="iface_orderline_notes" invisible="not pos_module_pos_restaurant">
|
||||
<field name="pos_iface_orderline_notes"/>
|
||||
</setting>
|
||||
<setting string="Early Receipt Printing" help="Allow to print receipt before payment" id="iface_printbill" invisible="not pos_module_pos_restaurant or is_kiosk_mode">
|
||||
<field name="pos_iface_printbill"/>
|
||||
</setting>
|
||||
<setting help="Split total or order lines" id="iface_splitbill" invisible="not pos_module_pos_restaurant or is_kiosk_mode">
|
||||
<field name="pos_iface_splitbill" string="Allow Bill Splitting"/>
|
||||
</setting>
|
||||
<setting help="Online reservation for restaurant" invisible="not pos_module_pos_restaurant or is_kiosk_mode">
|
||||
<field name="pos_module_pos_restaurant_appointment" string="Table Booking" widget="upgrade_boolean" />
|
||||
<div class="content-group" id="pos_table_booking" invisible="not pos_module_pos_restaurant_appointment">
|
||||
<div class="text-warning mt16 mb4">
|
||||
Save this page and come back here to set up the feature.
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
<div id="tip_product" position="after">
|
||||
<div invisible="not pos_module_pos_restaurant or not pos_iface_tipproduct">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<setting string="Sales Team" help="Sales are reported to the following sales team">
|
||||
<field name="pos_crm_team_id" context="{'kanban_view_ref': 'sales_team.crm_team_view_kanban'}" domain="['|', ('company_id', '=', company_id), ('company_id', '=', False)]"/>
|
||||
</setting>
|
||||
<setting string="Down Payment Product" help="This product will be applied when down payment is made">
|
||||
<setting id="down_payment_product" string="Down Payment Product" help="This product will be applied when down payment is made">
|
||||
<field
|
||||
name="pos_down_payment_product_id"
|
||||
domain="[('type', '=', 'service'), '|', ('company_id', '=', company_id), ('company_id', '=', False)]"
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/index.xml",
|
||||
"views/pos_self_order_mobile.index.xml",
|
||||
"views/pos_self_order_kiosk.index.xml",
|
||||
"views/qr_code.xml",
|
||||
"views/pos_config_view.xml",
|
||||
"views/custom_link_views.xml",
|
||||
"data/init_access.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
@@ -21,7 +23,9 @@
|
||||
"data/pos_restaurant_data.xml",
|
||||
],
|
||||
"assets": {
|
||||
"pos_self_order.assets_self_order": [
|
||||
# Assets
|
||||
"pos_self_order.assets_common": [
|
||||
"pos_self_order/static/src/kiosk/bootstrap_overridden.scss",
|
||||
"web/static/lib/jquery/jquery.js",
|
||||
("include", "web._assets_helpers"),
|
||||
("include", "web._assets_backend_helpers"),
|
||||
@@ -37,19 +41,37 @@
|
||||
"web/static/src/views/fields/formatters.js",
|
||||
"web/static/src/libs/fontawesome/css/font-awesome.css",
|
||||
"web/static/lib/odoo_ui_icons/*",
|
||||
"pos_self_order/static/src/**/*",
|
||||
'web/static/src/legacy/scss/ui.scss',
|
||||
"point_of_sale/static/src/utils.js",
|
||||
# bus service
|
||||
'bus/static/src/services/bus_service.js',
|
||||
'bus/static/src/bus_parameters_service.js',
|
||||
'bus/static/src/multi_tab_service.js',
|
||||
'bus/static/src/workers/*',
|
||||
"pos_self_order/static/src/common/**/*",
|
||||
],
|
||||
"pos_self_order.assets_tests": [
|
||||
"pos_self_order.assets_mobile": [
|
||||
('include', 'pos_self_order.assets_common'),
|
||||
"pos_self_order/static/src/mobile/**/*",
|
||||
],
|
||||
"pos_self_order.assets_kiosk": [
|
||||
'web/static/lib/bootstrap/js/dist/dom/data.js',
|
||||
'web/static/lib/bootstrap/js/dist/dom/event-handler.js',
|
||||
'web/static/lib/bootstrap/js/dist/dom/manipulator.js',
|
||||
'web/static/lib/bootstrap/js/dist/dom/selector-engine.js',
|
||||
'web/static/lib/bootstrap/js/dist/base-component.js',
|
||||
"web/static/lib/bootstrap/js/dist/carousel.js",
|
||||
('include', 'pos_self_order.assets_common'),
|
||||
"pos_self_order/static/src/kiosk/**/*",
|
||||
],
|
||||
# Assets tests
|
||||
"pos_self_order.assets_common_tests": [
|
||||
"web/static/lib/jquery/jquery.js",
|
||||
"web_tour/static/src/tour_pointer/**/*.xml",
|
||||
"web_tour/static/src/tour_pointer/**/*.js",
|
||||
"web_tour/static/src/tour_service/**/*",
|
||||
],
|
||||
"pos_self_order.assets_mobile_tests": [
|
||||
('include', 'pos_self_order.assets_common_tests'),
|
||||
"pos_self_order/static/tests/tours/**/*",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import entry
|
||||
from . import mobile_entry
|
||||
from . import kiosk_entry
|
||||
from . import orders
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.pos_self_order.controllers.utils import reduce_privilege
|
||||
|
||||
|
||||
class PosSelfKiosk(http.Controller):
|
||||
# The subpath is necessary because the frontend takes care of route management.
|
||||
# Sometimes the URL can be: http://localhost:4444/kiosk/5/product/42
|
||||
@http.route(["/kiosk/<config_id>", "/kiosk/<config_id>/<path:subpath>"], 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/<int:category_id>",
|
||||
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()
|
||||
)
|
||||
+12
-30
@@ -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/<int:pos_config_id>", 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()
|
||||
)
|
||||
@@ -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/<device_type>/", 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/<int:pos_config_id>/<device_type>', 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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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`<t t-slot="{{activeSlot}}" t-props="slotProps"/>`;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
$border-radius: .65rem !default;
|
||||
$border-radius-lg: .85rem !default;
|
||||
+67
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.AttributeSelection" owl="1">
|
||||
<div class="o_kiosk-attribute_selection">
|
||||
<div class="attribute_name py-1 mt-3 mb-4 fw-bold">
|
||||
<t t-if="!state.showResume">
|
||||
<h1>Choose your <span t-esc="attribute.name" /></h1>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h1>Your resume</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!state.showResume" class="attribute-selection-content align-items-center justify-content-start">
|
||||
<div class="row row-cols-3 g-3">
|
||||
<t t-foreach="attribute.values" t-as="value" t-key="value.id">
|
||||
<div class="col">
|
||||
<label t-attf-for="{{ attribute.id }}_{{ value.id }}" t-attf-class="{{ this.props.productState.selectedVariants[attribute.name] == value.name ? 'border-2 border-primary' : '' }} o_kiosk-card d-flex btn btn-light flex-column align-items-center justify-content-center p-0 rounded border overflow-hidden">
|
||||
<div class="name position-relative d-flex flex-column justify-content-center align-items-center flex-grow-1 w-100 py-2 text-center">
|
||||
<h2 class="m-0" t-esc="value.name" />
|
||||
<span t-if="value.price_extra.list_price">
|
||||
+ <t t-esc="selfOrder.formatMonetary(value.price_extra.list_price)" />
|
||||
</span>
|
||||
<span t-if="this.props.productState.selectedVariants[attribute.name] == value.name" class="selected-badge position-absolute top-0 start-50 translate-middle-x px-3 rounded-bottom text-bg-primary">
|
||||
Selected
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
class="d-none"
|
||||
t-att-value="value.name"
|
||||
t-on-click="attributeClicked"
|
||||
t-attf-id="{{ attribute.id }}_{{ value.id }}"
|
||||
t-model="this.props.productState.selectedVariants[attribute.name]" />
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="attribute-selection-content d-flex flex-column gap-3">
|
||||
<t t-foreach="attributeSelected" t-as="value" t-key="value.name">
|
||||
<div class="d-flex flex-row overflow-hidden rounded shadow-sm">
|
||||
<div class="name position-relative d-flex flex-row justify-content-center align-items-center w-100 flex-grow-1 p-3 bg-view text-center">
|
||||
<div class="d-flex flex-column flex-grow-1 align-items-start">
|
||||
<h2 class="m-0" t-esc="value.name" />
|
||||
<span class="px-1" t-esc="value.value" />
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-lg px-5 py-2" t-on-click="() => this.editAttribute(value.name)">
|
||||
EDIT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.o_kiosk-cancel_popup {
|
||||
.modal-dialog {
|
||||
height: auto !important;
|
||||
top: 40%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.CancelPopup" owl="1">
|
||||
<div class="o_kiosk-cancel_popup o_dialog" t-att-id="id">
|
||||
<div role="dialog" class="modal d-block" tabindex="-1">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content rounded">
|
||||
<div class="modal-body p-5">
|
||||
<div class="pb-5 fs-3 text-center">
|
||||
Are you sure you want to cancel this order? <br/>
|
||||
<span class="text-muted fs-4">All the items will be removed from the cart.</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-center w-100 gap-3">
|
||||
<button type="button" class="btn btn-primary btn-lg popup_button" t-on-click="() => this.confirm()">Cancel Order</button>
|
||||
<button type="button" class="btn btn-secondary btn-lg popup_button" t-on-click="() => this.props.close()">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.ComboSelection" owl="1">
|
||||
<div class="o_kiosk-attribute_selection h-100 p-4">
|
||||
<t t-if="!props.comboState.selectedProduct">
|
||||
<div class="attribute_name py-1 mt-3 mb-4 fw-bold">
|
||||
<h1>Choose your <t t-esc="props.combo.name" /></h1>
|
||||
</div>
|
||||
<div class="combo-list align-items-center justify-content-start">
|
||||
<div class="row row-cols-3 g-3">
|
||||
<t t-foreach="props.combo.combo_line_ids" t-as="line" t-key="line.id">
|
||||
<div class="col">
|
||||
<div
|
||||
t-attf-for="{{ props.combo.id }}_{{ line.id }}"
|
||||
class="o_kiosk-card position-relative btn btn-light d-flex flex-column align-items-center justify-content-center p-3 rounded border overflow-hidden"
|
||||
t-on-click="() => this.productClicked(line.id)">
|
||||
<t t-set="product" t-value="this.selfOrder.productByIds[line.product_id[0]]"/>
|
||||
<img
|
||||
class="h-100 w-100 rounded bg-view"
|
||||
t-attf-src="/menu/get-image/{{ product.id }}/512?unique={{product.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<div class="name d-flex flex-column justify-content-center align-items-center flex-grow-1 w-100 pt-3 text-center">
|
||||
<h2 class="m-0" t-esc="line.product_id[1]" />
|
||||
<span t-if="line.price" class="position-absolute top-0 end-0 m-1 px-2 rounded border border-white text-bg-primary bg-opacity-75 fs-4">
|
||||
+ <t t-esc="selfOrder.formatMonetary(line.price)" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<AttributeSelection
|
||||
product="props.comboState.selectedProduct"
|
||||
productState="props.comboState"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.o_kiosk-language_popup {
|
||||
.modal-dialog {
|
||||
top: 40%;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 64px;
|
||||
aspect-ratio: 4/3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.LanguagePopup" owl="1">
|
||||
<div class="o_kiosk-language_popup o_dialog" t-att-id="id">
|
||||
<div role="dialog" class="modal d-block" tabindex="-1">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body p-5">
|
||||
<div class="d-grid gap-3">
|
||||
<t t-foreach="languages" t-as="lang" t-key="lang.id" >
|
||||
<t t-if="lang.id !== currentLanguage.id">
|
||||
<div class="btn btn-light d-flex flex-row align-items-center rounded border p-4" t-on-click="() => this.onClickLanguage(lang)">
|
||||
<img class="rounded-2" t-attf-src="{{lang.flag_image_url}}" />
|
||||
<span class="fs-5 ms-4" t-esc="lang.display_name" />
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-center w-100 mt-5">
|
||||
<button type="button" class="btn btn-secondary btn-lg popup_button" t-on-click="() => this.props.close()">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.ProductCard" owl="1">
|
||||
<button class="o_kiosk_product_card product-item btn btn-light d-flex flex-column p-3 m-0 overflow-hidden border fw-bolder"
|
||||
t-on-click="selectProduct"
|
||||
t-ref="selfProductCard">
|
||||
<img
|
||||
class="o_self_order_item_card_image w-100 rounded bg-view"
|
||||
t-attf-src="/menu/get-image/{{ props.product.id }}/512?unique={{props.product.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<div class="product-infos d-flex flex-column justify-content-between text-start flex-grow-1 px-2 mt-3 mb-1">
|
||||
<span t-esc="props.product.name" class="fs-3 fw-bolder lh-sm" />
|
||||
<span t-esc="selfOrder.formatMonetary(props.product.prices)" class="fs-4 text-muted flex-grow-1" />
|
||||
</div>
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.Closed" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="100" footerHeight="0">
|
||||
<t t-set-slot="content">
|
||||
<div class="d-flex gap-3 h-100 w-100 align-items-center justify-content-center text-bg-800">
|
||||
<span class="fs-1">The kiosk is closed</span>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.Combo" owl="1">
|
||||
<KioskTemplate headerHeight="10" contentHeight="80" footerHeight="10">
|
||||
<t t-set-slot="header">
|
||||
<div class="d-flex flex-column h-100 w-100 fs-2 text-center border-bottom">
|
||||
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-primary text-white"><t t-esc="props.product.name" /></div>
|
||||
<div t-if="!state.showResume" class="breadcrumb h-100 w-100 d-flex flex-nowrap p-0 bg-200 overflow-x-auto overflow-y-hidden">
|
||||
<t t-foreach="props.product.pos_combo_ids" t-as="combo_id" t-key="combo_id">
|
||||
<div t-attf-class="d-flex align-items-center justify-content-center {{ currentComboId === combo_id ? 'active-step bg-100' : 'bg-200' }}">
|
||||
<span class="position-relative d-block pe-3" t-esc="selfOrder.comboByIds[combo_id].name" />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="content">
|
||||
<div class="h-100 overflow-y-auto">
|
||||
<t t-if="state.selectedProduct">
|
||||
<div class="d-flex flex-column h-25">
|
||||
<div t-attf-class="h-100 m-4 mb-0">
|
||||
<div class="d-flex flex-row h-100 pb-5 bg-view rounded shadow-sm">
|
||||
<img
|
||||
class="o_self_order_item_card_image w-25 p-3 rounded bg-view"
|
||||
style="object-fit: contain;"
|
||||
t-attf-src="/menu/get-image/{{ state.selectedProduct.id }}/512?unique={{state.selectedProduct.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<div class="d-flex flex-column justify-content-center w-75 p-3 ps-0">
|
||||
<span class="fs-1 fw-bolder" t-esc="state.selectedProduct.name"/>
|
||||
<div t-if="state.selectedProduct.description_sale"
|
||||
class="o_self_order_main_desc text-muted overflow-y-auto"
|
||||
t-esc="state.selectedProduct.description_sale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="d-flex flex-column h-75" t-if="!state.showResume">
|
||||
<div t-attf-class="h-100 {{ state.selectedProduct ? 'm-4 mt-0 rounded-bottom-3' : '' }}">
|
||||
<ComboSelection combo="currentCombo" comboState="state" next.bind="next"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" t-attf-class="o_kiosk-combo d-flex flex-column {{ state.showResume ? 'h-75' : 'h-50' }} p-4 overflow-auto">
|
||||
<div class="attribute_name py-1 mt-3 mb-4 fw-bold">
|
||||
<h1>Your Selection</h1>
|
||||
</div>
|
||||
<div class="h-100 align-items-center justify-content-start">
|
||||
<div class="d-flex flex-column">
|
||||
<div t-foreach="state.selectedCombos" t-as="combo" t-key="combo.id" class="d-flex align-items-center justify-content-between w-100 gap-3 py-3 p-2 pe-4 mb-3 rounded shadow-sm bg-view">
|
||||
<t t-set="product" t-value="this.selfOrder.productByIds[combo.product.id]"/>
|
||||
<img
|
||||
class="o_self_order_item_card_image w-auto h-100 rounded bg-view"
|
||||
t-attf-src="/menu/get-image/{{ combo.product.id }}/512?unique={{product.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<div class="name d-flex flex-column justify-content-center align-items-start flex-grow-1 h-auto w-100 text-center">
|
||||
<h2 class="m-0"><t t-esc="combo.product.name"/></h2>
|
||||
<div t-foreach="combo.product.variants" t-as="attribute" t-key="attribute_index">
|
||||
- <span t-esc="attribute[0]" /> : <span t-esc="attribute[1]" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-lg px-5 py-2" t-on-click="() => this.editCombo(combo.id)">EDIT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="state.showResume">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center w-100 h-25 p-5 bg-view border-top" style="z-index:1">
|
||||
<div class="o_self_order_incr_button d-flex flex-row text-center border-light mb-1" role="group" aria-label="Quantity select">
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(false)"
|
||||
class="btn btn-secondary fs-1 px-5 py-3"> - </button>
|
||||
<div class="d-flex align-items-center px-5 fs-1 fw-bold" t-esc="state.qty"/>
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(true)"
|
||||
class="btn btn-secondary fs-1 px-5 py-3"> + </button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-row h-100 w-100 gap-3">
|
||||
<button class="btn btn-secondary h-100 w-100 p-0 m-0 fs-2 flex-grow-1 text-uppercase" t-on-click="back">Discard</button>
|
||||
<button t-if="!state.showResume and state.showQtyButtons" class="btn btn-primary h-100 w-100 p-0 m-0 fs-2 text-uppercase" t-on-click="next">Next</button>
|
||||
<button t-if="state.showResume" class="btn btn-primary h-100 w-100 p-0 m-0 fs-2 text-uppercase" t-on-click="addToCart">Add to cart</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.EatingLocation" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="90" footerHeight="10">
|
||||
<t t-set-slot="content">
|
||||
<div class="o_kiosk_eating_location d-flex flex-column h-100 align-items-center bg-100 overflow-hidden">
|
||||
<div class="d-flex align-items-center justify-content-center flex-column w-100 h-100 my-auto px-5 text-center">
|
||||
<h1>Choose your eating location</h1>
|
||||
<div class="d-flex gap-3 h-50 h-lg-25 w-100 mt-5">
|
||||
<button t-on-click="() => this.selectLocation('in')" class="btn btn-light d-flex flex-column align-items-center h-100 w-50 p-0 border">
|
||||
<div class="d-flex flex-column flex-grow-1 align-items-center justify-content-center w-100">
|
||||
<img src="/pos_self_order/static/img/eatin.svg" />
|
||||
</div>
|
||||
<span class="w-100 rounded-bottom border-top fs-1">Eat In</span>
|
||||
</button>
|
||||
<button t-on-click="() => this.selectLocation('out')" class="btn btn-light d-flex flex-column align-items-center h-100 w-50 p-0 border">
|
||||
<div class="d-flex flex-column flex-grow-1 align-items-center justify-content-center w-100">
|
||||
<img src="/pos_self_order/static/img/takeAway.svg" />
|
||||
</div>
|
||||
<span class="w-100 rounded-bottom border-top fs-1">Take Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex w-100 h-100">
|
||||
<button class="h-100 btn btn-secondary w-100 p-0 m-0 fs-2 text-uppercase" t-on-click="back">
|
||||
<i class="oi oi-chevron-left"/>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.LandingPage" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="90" footerHeight="10">
|
||||
<t t-set-slot="content">
|
||||
<div t-if="languages.length > 1" t-on-click="openLanguages" class="o_kiosk_language_selector position-absolute top-0 end-0 m-4 rounded p-4 bg-white shadow-lg">
|
||||
<img class="rounded" t-attf-src="{{currentLanguage.flag_image_url}}" />
|
||||
<span t-esc="currentLanguage.display_name" class="ms-3"></span>
|
||||
</div>
|
||||
<div t-if="selfOrder.kiosk_image_home" t-on-click="start" class="o_kiosk_home_page d-flex flex-column vh-100 align-items-center overflow-hidden">
|
||||
<!-- Carousel ready, just need to add a way to add more than 1 image in the setting. -->
|
||||
<div id="imageCarousel" class="carousel slide h-100" data-bs-ride="carousel">
|
||||
<div class="carousel-inner h-100">
|
||||
<div class="carousel-item active h-100">
|
||||
<img t-attf-src="data:image/png;base64,{{selfOrder.kiosk_image_home}}" class="o_kiosk-landing_image d-block h-100 w-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary h-100 w-100 p-0 m-0 fs-1 text-uppercase" t-on-click="start">TOUCH TO START</button>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.OrderCart" owl="1">
|
||||
<KioskTemplate headerHeight="8" contentHeight="82" footerHeight="10">
|
||||
<t t-set-slot="header">
|
||||
<div class="d-flex align-items-end justify-content-center pb-4 w-100 h-100 bg-100">
|
||||
<h1 class="mb-0 fs-1 fw-bolder">Verify your Order</h1>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="content">
|
||||
<div class="o_kiosk-order-cart position-relative h-100 d-flex flex-column justify-content-between bg-100">
|
||||
<div class="order-content bg-100 px-3 py-4 overflow-auto">
|
||||
<t t-foreach="lines" t-as="line" t-key="line.uuid">
|
||||
<div t-if="!line.combo_parent_uuid" class="d-flex align-items-start justify-content-between w-100 gap-3 py-3 p-2 pe-4 mb-3 rounded shadow-sm bg-view">
|
||||
<t t-set="childLines" t-value="getChildLines(line)"/>
|
||||
<t t-set="product" t-value="this.selfOrder.productByIds[line.product_id]"/>
|
||||
<img
|
||||
class="o_self_order_item_card_image ratio ratio-1x1 w-auto h-100 rounded bg-view"
|
||||
t-attf-src="/menu/get-image/{{ product.id }}/128?unique={{product.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<div class="product-wrapper d-flex flex-grow-1">
|
||||
<div class="product-info d-flex flex-row align-items-center justify-content-center flex-grow-1 h-auto">
|
||||
<div class="d-flex flex-column flex-grow-1 justify-content-center">
|
||||
<div class="fs-2 fw-bolder"><span t-esc="line.full_product_name"/></div>
|
||||
|
||||
<div t-foreach="childLines" t-as="cline" t-key="cline.uuid" class="text-muted">
|
||||
- <span t-esc="cline.full_product_name" /> <span t-if="cline.price_subtotal_incl">(<t t-esc="selfOrder.formatMonetary(cline.price_subtotal_incl)"/>)</span>
|
||||
<div class="product-info d-flex flex-column flex-grow-1 ms-3">
|
||||
<div t-foreach="cline.attributes" t-as="attribute" t-key="attribute.name">
|
||||
- <span t-esc="attribute.name" /> : <span t-esc="attribute.value" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-foreach="line.attributes" t-as="attribute" t-key="attribute.name" class="text-muted">
|
||||
- <span t-esc="attribute.name" /> : <span t-esc="attribute.value" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="line-price d-flex flex-column align-items-end justify-content-between h-auto w-25">
|
||||
<span class="fs-3 fw-bolder" t-esc="selfOrder.formatMonetary(getPrice(line))"/>
|
||||
<div class="o_self_order_incr_button d-flex flex-row text-center border-light mb-1">
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(line, false)"
|
||||
t-attf-class="btn {{ line.qty === 1 ? 'btn-danger' : 'btn-secondary'}} rounded fs-2 px-5 py-2">
|
||||
<i t-if="line.qty === 1" class="fa fa-trash" aria-hidden="true"></i>
|
||||
<span t-else=""> - </span>
|
||||
</button>
|
||||
<div class="d-flex align-items-center px-5 fs-2 fw-bold" t-esc="line.qty"/>
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(line, true)"
|
||||
class="btn btn-secondary fs-2 px-5 py-2 rounded" > + </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="order-price absolute-content position-absolute fixed-bottom w-100">
|
||||
<div class="absolute-content-price d-flex flex-column justify-content-center py-4 px-5 border-top bg-view text-end lh-sm">
|
||||
<p class="mb-0 fs-1 fw-bolder">Total: <t t-esc="selfOrder.formatMonetary(selfOrder.currentOrder.amount_total)" /></p>
|
||||
<p class="mb-0 fs-2 text-muted">Taxes: <t t-esc="selfOrder.formatMonetary(selfOrder.currentOrder.amount_tax)" /></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-row w-100 h-100 gap-3">
|
||||
<button class="btn btn-secondary w-100 h-100 p-0 m-0 fs-2 text-uppercase" t-on-click="backToMenu">Back to Menu</button>
|
||||
<button t-attf-class="{{ selfOrder.currentOrder.lines.length === 0 ? 'disabled' : '' }} w-100 h-100 btn btn-primary p-0 m-0 fs-2 text-uppercase" t-on-click="pay">Pay <t t-if="selfOrder.pos_payment_methods.length === 0">at counter</t></button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.Payment" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="showFooterBtn ? 92 : 100" footerHeight="showFooterBtn ? 8 : 0">
|
||||
<t t-set-slot="content">
|
||||
<div t-if="state.selection" class="h-100 d-flex flex-wrap p-5 gap-5 align-items-center justify-content-center">
|
||||
<div
|
||||
t-foreach="selfOrder.pos_payment_methods"
|
||||
t-as="payment_method"
|
||||
t-key="payment_method.id"
|
||||
class="o_kiosk-card border rounded d-flex flex-column align-items-center justify-content-center"
|
||||
t-on-click="() => this.selectMethod(payment_method.id)">
|
||||
<div class="h-75 fs-1 d-flex flex-column justify-content-center align-items-center">
|
||||
<i class="fa fa-credit-card" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="name w-100 d-flex justify-content-center align-items-center h-25">
|
||||
<span t-esc="payment_method.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="h-100">
|
||||
<div class="d-flex flex-column gap-5 justify-content-center align-items-center h-100">
|
||||
<t t-if="!this.selfOrder.paymentError">
|
||||
<h1 class="text-center">Follow instructions<br/>on the terminal</h1>
|
||||
<i class="fa fa-credit-card-alt fs-1" aria-hidden="true"></i>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h1 class="text-center">The payment has failed<br/>please retry</h1>
|
||||
<i class="fa fa-exclamation-circle fs-1" aria-hidden="true"></i>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex h-100 w-100">
|
||||
<button t-attf-class="{{ this.selfOrder.paymentError ? 'w-50' : 'w-100' }} h-100 btn btn-info p-0 m-0 rounded-0 fs-2 text-uppercase" t-on-click="() => this.router.back()">Back</button>
|
||||
<button t-attf-class="{{ this.selfOrder.paymentError ? 'w-50' : 'w-0' }} h-100 btn btn-success p-0 m-0 rounded-0 fs-2 text-uppercase" t-on-click="startPayment">Retry</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.number {
|
||||
font-size: 6rem;
|
||||
}
|
||||
.o_kiosk-success_loader {
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.PaymentSuccess" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="90" footerHeight="10">
|
||||
<t t-set-slot="header">
|
||||
<div t-if="this.state.onReload" class="o_kiosk-success_loader position-absolute vh-100 w-100 d-flex justify-content-center align-items-center opacity-50 bg-dark">
|
||||
<div class="page-loader d-flex justify-content-center align-items-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="content">
|
||||
<div class="h-100 d-flex justify-content-center align-items-center flex-column text-center">
|
||||
<h1 class="m-0">We're preparing<br/>your order!</h1>
|
||||
<div class="d-flex flex-column justify-content-center align-items-center w-50 h-25 my-5 border rounded bg-view">
|
||||
<t t-if="selfOrder.currentOrder.take_away || selfOrder.kiosk_mode !== 'table'">
|
||||
<span class="fs-2 text-muted">Your order number</span>
|
||||
<span class="number m-0" t-esc="selfOrder.currentOrder.tracking_number" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="fs-2 text-muted">Your table number</span>
|
||||
<span class="number m-0" t-esc="selfOrder.tablePadNumber" />
|
||||
<h2 class="m-0 text-muted">Order number: <t t-esc="selfOrder.currentOrder.tracking_number" /></h2>
|
||||
</t>
|
||||
</div>
|
||||
<h2 class="m-0 text-muted">Get your ticket below</h2>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-row h-100 w-100 gap-3">
|
||||
<button class="btn btn-primary w-100 h-100 p-0 m-0 fs-2 text-uppercase" t-on-click="backToHome">Close</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.Product" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="90" footerHeight="10">
|
||||
<t t-set-slot="header">
|
||||
</t>
|
||||
<t t-set-slot="content">
|
||||
<div class="h-100">
|
||||
<div class="d-flex flex-row align-content-flex-start p-3 gap-3 h-25">
|
||||
<img
|
||||
class="o_self_order_item_card_image w-25 py-2 ratio ratio-1x1 rounded shadow-sm bg-view"
|
||||
style="object-fit: contain;"
|
||||
t-attf-src="/menu/get-image/{{ product.id }}/512?unique={{product.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<div class="d-flex flex-column flex-grow-1 w-75 justify-content-start p-4 rounded shadow-sm bg-view overflow-auto">
|
||||
<h1 class="fw-bolder" t-esc="product.name"/>
|
||||
<span t-if="product.description_sale"
|
||||
class="o_self_order_main_desc flex-grow-1 pb-3 mb-3 bg-view text-muted "
|
||||
t-esc="product.description_sale"
|
||||
/>
|
||||
<span class="fs-3 fw-bolder" t-esc="selfOrder.formatMonetary(product.prices)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-attf-class="{{ state.showQtyButtons ? 'h-50' : 'h-75' }}">
|
||||
<div class="h-100 px-4 overflow-y-auto">
|
||||
<AttributeSelection
|
||||
t-if="attributeSelection"
|
||||
product="product"
|
||||
productState="state"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.showQtyButtons" class="d-flex flex-column justify-content-center align-items-center w-100 h-25 p-5 bg-view border-top" style="z-index:1">
|
||||
<div class="o_self_order_incr_button d-flex flex-row text-center border-light mb-1" role="group" aria-label="Quantity select">
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(false)"
|
||||
class="btn btn-secondary px-5 py-3 fs-1 "> - </button>
|
||||
<div class="d-flex align-items-center px-5 fs-1 fw-bold" t-esc="state.qty"/>
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(true)"
|
||||
class="btn btn-secondary px-5 py-3 fs-1 "> + </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-row w-100 h-100 gap-3">
|
||||
<button class="btn btn-secondary w-100 h-100 p-0 m-0 fs-2 flex-grow-1 text-uppercase" t-on-click="back">Discard</button>
|
||||
<button t-if="state.showQtyButtons and !props.onValidate" class="btn btn-primary w-100 h-100 p-0 m-0 fs-2 text-uppercase" t-on-click="addToCart">Add to cart</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.ProductList" owl="1">
|
||||
<t t-set="categories" t-value="Array.from(selfOrder.categoryList)" />
|
||||
<KioskTemplate headerHeight="selfOrder.kiosk_image_brand ? 8 : 0" contentHeight="selfOrder.kiosk_image_brand ? 82 : 90" footerHeight="10">
|
||||
<t t-set-slot="header">
|
||||
<div class="header-image h-100" t-attf-style="background-image: url('data:image/png;base64,{{selfOrder.kiosk_image_brand}}')" />
|
||||
</t>
|
||||
<t t-set-slot="content">
|
||||
<div class="o_kiosk_product_list h-100 d-flex flex-column">
|
||||
<div class="category-list d-flex flex-column flex-wrap gap-3 align-content-start p-3 shadow-sm bg-view z-index-1" t-ref="categoryList">
|
||||
<button
|
||||
t-foreach="categories"
|
||||
t-as="category"
|
||||
t-key="category.id"
|
||||
t-ref="category_{{category.name}}"
|
||||
t-attf-class="{{selfOrder.currentCategory === category.name ? 'border-2 border-primary' : ''}} btn btn-light d-flex flex-column align-items-center justify-content-center category-item h-100 border p-2 overflow-hidden"
|
||||
t-on-click="() => this.categoryClicked(category.name)">
|
||||
<img t-if="category.has_image"
|
||||
class="rounded w-100 h-75 ratio ratio-1x1"
|
||||
t-attf-src="/kiosk/get-category-image/{{ category.id }}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
onerror="this.remove()" />
|
||||
<span class="w-100 fs-3 text-truncate" t-esc="category.name" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="product-list pb-4" t-ref="productsList">
|
||||
<div>
|
||||
<h2 class="pt-5 pb-1 px-4 m-0 fs-1 " t-esc="selfOrder.currentCategory" />
|
||||
<div class="product-list-category d-grid gap-3 p-3">
|
||||
<t t-foreach="productCategory" t-as="product" t-key="product.id">
|
||||
<ProductCard product="product" currentProductCard="product.id === selfOrder.lastEditedProductId and currentProductCard" />
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-row w-100 h-100 gap-3">
|
||||
<button class="btn btn-secondary h-100 w-100 p-0 m-0 fs-2 text-uppercase" t-on-click="cancelOrder">CANCEL ORDER</button>
|
||||
<button t-attf-class="{{ selfOrder.currentOrder.lines.length === 0 ? 'd-none' : '' }} cart btn btn-primary h-100 w-100 d-flex flex-column justify-content-center align-items-center p-0 m-0 fs-2 text-uppercase" t-on-click="review">
|
||||
Review Order
|
||||
<span t-attf-class="total-price {{ selfOrder.currentOrder.lines.length === 0 ? 'd-none' : '' }} fs-4">
|
||||
Total: <t t-esc="selfOrder.formatMonetary(selfOrder.currentOrder.amount_total)" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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 : "_ _";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.StandNumber" owl="1">
|
||||
<KioskTemplate headerHeight="0" contentHeight="90" footerHeight="10">
|
||||
<t t-set-slot="content">
|
||||
<div class="h-100 o_kiosk-stand_number">
|
||||
<div class="d-flex flex-column justify-content-between h-100">
|
||||
<div class="h-100 d-flex flex-column justify-content-center align-items-center">
|
||||
<h1>Pick a plate and write your number here</h1>
|
||||
<div class="input-number w-50 py-3 px-5 mt-5 text-center rounded border border-dark">
|
||||
<span t-esc="tableInput" class="fs-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="numpad row row-cols-3">
|
||||
<div t-on-click="numberClick" data="1" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">1</div>
|
||||
<div t-on-click="numberClick" data="2" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">2</div>
|
||||
<div t-on-click="numberClick" data="3" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">3</div>
|
||||
<div t-on-click="numberClick" data="4" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">4</div>
|
||||
<div t-on-click="numberClick" data="5" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">5</div>
|
||||
<div t-on-click="numberClick" data="6" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">6</div>
|
||||
<div t-on-click="numberClick" data="7" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">7</div>
|
||||
<div t-on-click="numberClick" data="8" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">8</div>
|
||||
<div t-on-click="numberClick" data="9" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">9</div>
|
||||
<div t-on-click="numberClick" data="clear" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">
|
||||
<i class="fa oi-close" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div t-on-click="numberClick" data="0" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">0</div>
|
||||
<div t-on-click="numberClick" data="reset" class="touch-key col btn btn-light d-flex align-items-center justify-content-center py-5 border rounded-0 fs-1">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-row h-100 w-100 gap-3">
|
||||
<button class="btn btn-secondary h-100 w-100 p-0 m-0 fs-2 text-uppercase" t-on-click="() => this.router.back()">Back</button>
|
||||
<button class="btn btn-primary h-100 w-100 p-0 m-0 fs-2 text-uppercase" t-on-click="confirm">Pay</button>
|
||||
</div>
|
||||
</t>
|
||||
</KioskTemplate>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.selfOrderIndex" owl="1">
|
||||
<Router pos_config_id="selfOrder.pos_config_id">
|
||||
<t t-set-slot="default" route="`/kiosk/${selfOrder.pos_config_id}`">
|
||||
<LandingPage />
|
||||
</t>
|
||||
<t t-set-slot="closed" route="`/kiosk/${selfOrder.pos_config_id}/closed`">
|
||||
<Closed />
|
||||
</t>
|
||||
<t t-set-slot="payment_success" route="`/kiosk/${selfOrder.pos_config_id}/payment_success`">
|
||||
<PaymentSuccess />
|
||||
</t>
|
||||
<t t-set-slot="product_list" route="`/kiosk/${selfOrder.pos_config_id}/products`">
|
||||
<ProductList />
|
||||
</t>
|
||||
<t t-set-slot="location" route="`/kiosk/${selfOrder.pos_config_id}/location`">
|
||||
<EatingLocation />
|
||||
</t>
|
||||
<t t-set-slot="product" route="`/kiosk/${selfOrder.pos_config_id}/product/{int:id}`" t-slot-scope="url">
|
||||
<Product product="selfOrder.productByIds[url.id]" />
|
||||
</t>
|
||||
<t t-set-slot="combo" route="`/kiosk/${selfOrder.pos_config_id}/combo/{int:id}`" t-slot-scope="url">
|
||||
<Combo product="selfOrder.productByIds[url.id]" />
|
||||
</t>
|
||||
<t t-set-slot="cart" route="`/kiosk/${selfOrder.pos_config_id}/cart`">
|
||||
<OrderCart />
|
||||
</t>
|
||||
<t t-set-slot="stand_number" route="`/kiosk/${selfOrder.pos_config_id}/stand_number`">
|
||||
<StandNumber />
|
||||
</t>
|
||||
<t t-set-slot="payment" route="`/kiosk/${selfOrder.pos_config_id}/payment`">
|
||||
<Payment />
|
||||
</t>
|
||||
</Router>
|
||||
<MainComponentsContainer />
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.KioskTemplate" owl="1">
|
||||
<div class="o_kiosk-template h-100">
|
||||
<section class="header-section" t-attf-style="height: {{ isHeaderBanner ? props.headerHeight : '0' }}%;">
|
||||
<t t-slot="header" />
|
||||
</section>
|
||||
<section class="content-section position-relative z-index-0" t-attf-style="height: {{ isHeaderBanner ? props.contentHeight : props.contentHeight + props.headerHeight }}%;">
|
||||
<t t-slot="content" />
|
||||
</section>
|
||||
<section t-attf-class="position-relative p-3 bg-transparent z-index-1 {{ showShadow ? 'o_footer_shadow' : '' }}" t-attf-style="height: {{ props.footerHeight }}%;">
|
||||
<t t-slot="footer" />
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
+1
-1
@@ -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";
|
||||
+1
-1
@@ -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";
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
+1
-1
@@ -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 {
|
||||
+1
-4
@@ -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;
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<img
|
||||
class="o_self_order_item_card_image h-100 w-100 ms-2 rounded-3 border border-light bg-light"
|
||||
t-attf-src="/menu/get-image/{{ props.product.id }}"
|
||||
t-attf-src="/menu/get-image/{{ props.product.id }}/512?unique={{props.product.write_date}}"
|
||||
alt="Product image"
|
||||
loading="lazy"
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user