summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/static/src/js/Screens
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static/src/js/Screens
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static/src/js/Screens')
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js129
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js17
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js182
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js155
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js36
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js29
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js214
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js31
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js124
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js101
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js42
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js55
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js32
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js17
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js376
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js23
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js23
-rw-r--r--addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js30
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js42
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js80
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js67
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js47
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js59
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js110
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js25
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js49
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js13
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js327
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js88
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js33
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js47
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js123
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js18
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js102
-rw-r--r--addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js220
43 files changed, 3204 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js
new file mode 100644
index 00000000..3c126ec2
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js
@@ -0,0 +1,129 @@
+odoo.define('point_of_sale.ClientDetailsEdit', function(require) {
+ 'use strict';
+
+ const { _t } = require('web.core');
+ const { getDataURLFromFile } = require('web.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ClientDetailsEdit extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.intFields = ['country_id', 'state_id', 'property_product_pricelist'];
+ const partner = this.props.partner;
+ this.changes = {
+ 'country_id': partner.country_id && partner.country_id[0],
+ 'state_id': partner.state_id && partner.state_id[0],
+ };
+ }
+ mounted() {
+ this.env.bus.on('save-customer', this, this.saveChanges);
+ }
+ willUnmount() {
+ this.env.bus.off('save-customer', this);
+ }
+ get partnerImageUrl() {
+ // We prioritize image_1920 in the `changes` field because we want
+ // to show the uploaded image without fetching new data from the server.
+ const partner = this.props.partner;
+ if (this.changes.image_1920) {
+ return this.changes.image_1920;
+ } else if (partner.id) {
+ return `/web/image?model=res.partner&id=${partner.id}&field=image_128&write_date=${partner.write_date}&unique=1`;
+ } else {
+ return false;
+ }
+ }
+ /**
+ * Save to field `changes` all input changes from the form fields.
+ */
+ captureChange(event) {
+ this.changes[event.target.name] = event.target.value;
+ }
+ saveChanges() {
+ let processedChanges = {};
+ for (let [key, value] of Object.entries(this.changes)) {
+ if (this.intFields.includes(key)) {
+ processedChanges[key] = parseInt(value) || false;
+ } else {
+ processedChanges[key] = value;
+ }
+ }
+ if ((!this.props.partner.name && !processedChanges.name) ||
+ processedChanges.name === '' ){
+ return this.showPopup('ErrorPopup', {
+ title: _t('A Customer Name Is Required'),
+ });
+ }
+ processedChanges.id = this.props.partner.id || false;
+ this.trigger('save-changes', { processedChanges });
+ }
+ async uploadImage(event) {
+ const file = event.target.files[0];
+ if (!file.type.match(/image.*/)) {
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Unsupported File Format'),
+ body: this.env._t(
+ 'Only web-compatible Image formats such as .png or .jpeg are supported.'
+ ),
+ });
+ } else {
+ const imageUrl = await getDataURLFromFile(file);
+ const loadedImage = await this._loadImage(imageUrl);
+ if (loadedImage) {
+ const resizedImage = await this._resizeImage(loadedImage, 800, 600);
+ this.changes.image_1920 = resizedImage.toDataURL();
+ // Rerender to reflect the changes in the screen
+ this.render();
+ }
+ }
+ }
+ _resizeImage(img, maxwidth, maxheight) {
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+ var ratio = 1;
+
+ if (img.width > maxwidth) {
+ ratio = maxwidth / img.width;
+ }
+ if (img.height * ratio > maxheight) {
+ ratio = maxheight / img.height;
+ }
+ var width = Math.floor(img.width * ratio);
+ var height = Math.floor(img.height * ratio);
+
+ canvas.width = width;
+ canvas.height = height;
+ ctx.drawImage(img, 0, 0, width, height);
+ return canvas;
+ }
+ /**
+ * Loading image is converted to a Promise to allow await when
+ * loading an image. It resolves to the loaded image if succesful,
+ * else, resolves to false.
+ *
+ * [Source](https://stackoverflow.com/questions/45788934/how-to-turn-this-callback-into-a-promise-using-async-await)
+ */
+ _loadImage(url) {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.addEventListener('load', () => resolve(img));
+ img.addEventListener('error', () => {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Loading Image Error'),
+ body: this.env._t(
+ 'Encountered error when loading image. Please try again.'
+ ),
+ });
+ resolve(false);
+ });
+ img.src = url;
+ });
+ }
+ }
+ ClientDetailsEdit.template = 'ClientDetailsEdit';
+
+ Registries.Component.add(ClientDetailsEdit);
+
+ return ClientDetailsEdit;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js
new file mode 100644
index 00000000..86f55645
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js
@@ -0,0 +1,17 @@
+odoo.define('point_of_sale.ClientLine', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ClientLine extends PosComponent {
+ get highlight() {
+ return this.props.partner !== this.props.selectedClient ? '' : 'highlight';
+ }
+ }
+ ClientLine.template = 'ClientLine';
+
+ Registries.Component.add(ClientLine);
+
+ return ClientLine;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js
new file mode 100644
index 00000000..4863d588
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js
@@ -0,0 +1,182 @@
+odoo.define('point_of_sale.ClientListScreen', function(require) {
+ 'use strict';
+
+ const { debounce } = owl.utils;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+
+ /**
+ * Render this screen using `showTempScreen` to select client.
+ * When the shown screen is confirmed ('Set Customer' or 'Deselect Customer'
+ * button is clicked), the call to `showTempScreen` resolves to the
+ * selected client. E.g.
+ *
+ * ```js
+ * const { confirmed, payload: selectedClient } = await showTempScreen('ClientListScreen');
+ * if (confirmed) {
+ * // do something with the selectedClient
+ * }
+ * ```
+ *
+ * @props client - originally selected client
+ */
+ class ClientListScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click-save', () => this.env.bus.trigger('save-customer'));
+ useListener('click-edit', () => this.editClient());
+ useListener('save-changes', this.saveChanges);
+
+ // We are not using useState here because the object
+ // passed to useState converts the object and its contents
+ // to Observer proxy. Not sure of the side-effects of making
+ // a persistent object, such as pos, into owl.Observer. But it
+ // is better to be safe.
+ this.state = {
+ query: null,
+ selectedClient: this.props.client,
+ detailIsShown: false,
+ isEditMode: false,
+ editModeProps: {
+ partner: {
+ country_id: this.env.pos.company.country_id,
+ state_id: this.env.pos.company.state_id,
+ }
+ },
+ };
+ this.updateClientList = debounce(this.updateClientList, 70);
+ }
+
+ // Lifecycle hooks
+ back() {
+ if(this.state.detailIsShown) {
+ this.state.detailIsShown = false;
+ this.render();
+ } else {
+ this.props.resolve({ confirmed: false, payload: false });
+ this.trigger('close-temp-screen');
+ }
+ }
+ confirm() {
+ this.props.resolve({ confirmed: true, payload: this.state.selectedClient });
+ this.trigger('close-temp-screen');
+ }
+ // Getters
+
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+
+ get clients() {
+ if (this.state.query && this.state.query.trim() !== '') {
+ return this.env.pos.db.search_partner(this.state.query.trim());
+ } else {
+ return this.env.pos.db.get_partners_sorted(1000);
+ }
+ }
+ get isNextButtonVisible() {
+ return this.state.selectedClient ? true : false;
+ }
+ /**
+ * Returns the text and command of the next button.
+ * The command field is used by the clickNext call.
+ */
+ get nextButton() {
+ if (!this.props.client) {
+ return { command: 'set', text: this.env._t('Set Customer') };
+ } else if (this.props.client && this.props.client === this.state.selectedClient) {
+ return { command: 'deselect', text: this.env._t('Deselect Customer') };
+ } else {
+ return { command: 'set', text: this.env._t('Change Customer') };
+ }
+ }
+
+ // Methods
+
+ // We declare this event handler as a debounce function in
+ // order to lower its trigger rate.
+ updateClientList(event) {
+ this.state.query = event.target.value;
+ const clients = this.clients;
+ if (event.code === 'Enter' && clients.length === 1) {
+ this.state.selectedClient = clients[0];
+ this.clickNext();
+ } else {
+ this.render();
+ }
+ }
+ clickClient(event) {
+ let partner = event.detail.client;
+ if (this.state.selectedClient === partner) {
+ this.state.selectedClient = null;
+ } else {
+ this.state.selectedClient = partner;
+ }
+ this.render();
+ }
+ editClient() {
+ this.state.editModeProps = {
+ partner: this.state.selectedClient,
+ };
+ this.state.detailIsShown = true;
+ this.render();
+ }
+ clickNext() {
+ this.state.selectedClient = this.nextButton.command === 'set' ? this.state.selectedClient : null;
+ this.confirm();
+ }
+ activateEditMode(event) {
+ const { isNewClient } = event.detail;
+ this.state.isEditMode = true;
+ this.state.detailIsShown = true;
+ this.state.isNewClient = isNewClient;
+ if (!isNewClient) {
+ this.state.editModeProps = {
+ partner: this.state.selectedClient,
+ };
+ }
+ this.render();
+ }
+ deactivateEditMode() {
+ this.state.isEditMode = false;
+ this.state.editModeProps = {
+ partner: {
+ country_id: this.env.pos.company.country_id,
+ state_id: this.env.pos.company.state_id,
+ },
+ };
+ this.render();
+ }
+ async saveChanges(event) {
+ try {
+ let partnerId = await this.rpc({
+ model: 'res.partner',
+ method: 'create_from_ui',
+ args: [event.detail.processedChanges],
+ });
+ await this.env.pos.load_new_partners();
+ this.state.selectedClient = this.env.pos.db.get_partner_by_id(partnerId);
+ this.state.detailIsShown = false;
+ this.render();
+ } catch (error) {
+ if (error.message.code < 0) {
+ await this.showPopup('OfflineErrorPopup', {
+ title: this.env._t('Offline'),
+ body: this.env._t('Unable to save changes.'),
+ });
+ } else {
+ throw error;
+ }
+ }
+ }
+ cancelEdit() {
+ this.deactivateEditMode();
+ }
+ }
+ ClientListScreen.template = 'ClientListScreen';
+
+ Registries.Component.add(ClientListScreen);
+
+ return ClientListScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js
new file mode 100644
index 00000000..53b858ba
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js
@@ -0,0 +1,155 @@
+odoo.define('point_of_sale.InvoiceButton', function (require) {
+ 'use strict';
+
+ const { useListener } = require('web.custom_hooks');
+ const { useContext } = owl.hooks;
+ const { isRpcError } = require('point_of_sale.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
+ const OrderFetcher = require('point_of_sale.OrderFetcher');
+ const Registries = require('point_of_sale.Registries');
+ const contexts = require('point_of_sale.PosContext');
+
+ class InvoiceButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this._onClick);
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ }
+ get selectedOrder() {
+ return this.orderManagementContext.selectedOrder;
+ }
+ set selectedOrder(value) {
+ this.orderManagementContext.selectedOrder = value;
+ }
+ get isAlreadyInvoiced() {
+ if (!this.selectedOrder) return false;
+ return Boolean(this.selectedOrder.account_move);
+ }
+ get commandName() {
+ if (!this.selectedOrder) {
+ return 'Invoice';
+ } else {
+ return this.isAlreadyInvoiced
+ ? 'Reprint Invoice'
+ : this.selectedOrder.isFromClosedSession
+ ? 'Cannot Invoice'
+ : 'Invoice';
+ }
+ }
+ get isHighlighted() {
+ return this.selectedOrder && !this.isAlreadyInvoiced && !this.selectedOrder.isFromClosedSession;
+ }
+ async _downloadInvoice(orderId) {
+ try {
+ await this.env.pos.do_action('point_of_sale.pos_invoice_report', {
+ additional_context: {
+ active_ids: [orderId],
+ },
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ // NOTE: error here is most probably undefined
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Network Error'),
+ body: this.env._t('Unable to download invoice.'),
+ });
+ }
+ }
+ }
+ async _invoiceOrder() {
+ const order = this.selectedOrder;
+ if (!order) return;
+
+ const orderId = order.backendId;
+
+ // Part 0.1. If already invoiced, print the invoice.
+ if (this.isAlreadyInvoiced) {
+ await this._downloadInvoice(orderId);
+ return;
+ }
+
+ // Part 0.2. Check if order belongs to an active session.
+ // If not, do not allow invoicing.
+ if (order.isFromClosedSession) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Session is closed'),
+ body: this.env._t('Cannot invoice order from closed session.'),
+ });
+ return;
+ }
+
+ // Part 1: Handle missing client.
+ // Write to pos.order the selected client.
+ if (!order.get_client()) {
+ const { confirmed: confirmedPopup } = await this.showPopup('ConfirmPopup', {
+ title: 'Need customer to invoice',
+ body: 'Do you want to open the customer list to select customer?',
+ });
+ if (!confirmedPopup) return;
+
+ const { confirmed: confirmedTempScreen, payload: newClient } = await this.showTempScreen(
+ 'ClientListScreen'
+ );
+ if (!confirmedTempScreen) return;
+
+ await this.rpc({
+ model: 'pos.order',
+ method: 'write',
+ args: [[orderId], { partner_id: newClient.id }],
+ kwargs: { context: this.env.session.user_context },
+ });
+ }
+
+ // Part 2: Invoice the order.
+ await this.rpc(
+ {
+ model: 'pos.order',
+ method: 'action_pos_order_invoice',
+ args: [orderId],
+ kwargs: { context: this.env.session.user_context },
+ },
+ {
+ timeout: 30000,
+ shadow: true,
+ }
+ );
+
+ // Part 3: Download invoice.
+ await this._downloadInvoice(orderId);
+
+ // Invalidate the cache then fetch the updated order.
+ OrderFetcher.invalidateCache([orderId]);
+ await OrderFetcher.fetch();
+ this.selectedOrder = OrderFetcher.get(this.selectedOrder.backendId);
+ }
+ async _onClick() {
+ try {
+ await this._invoiceOrder();
+ } catch (error) {
+ if (isRpcError(error) && error.message.code < 0) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Network Error'),
+ body: this.env._t('Unable to invoice order.'),
+ });
+ } else {
+ throw error;
+ }
+ }
+ }
+ }
+ InvoiceButton.template = 'InvoiceButton';
+
+ OrderManagementScreen.addControlButton({
+ component: InvoiceButton,
+ condition: function () {
+ return this.env.pos.config.module_account;
+ },
+ });
+
+ Registries.Component.add(InvoiceButton);
+
+ return InvoiceButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js
new file mode 100644
index 00000000..5a227827
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js
@@ -0,0 +1,36 @@
+odoo.define('point_of_sale.ReprintReceiptButton', function (require) {
+ 'use strict';
+
+ const { useListener } = require('web.custom_hooks');
+ const { useContext } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
+ const Registries = require('point_of_sale.Registries');
+ const contexts = require('point_of_sale.PosContext');
+
+ class ReprintReceiptButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this._onClick);
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ }
+ async _onClick() {
+ const order = this.orderManagementContext.selectedOrder;
+ if (!order) return;
+
+ this.showScreen('ReprintReceiptScreen', { order: order });
+ }
+ }
+ ReprintReceiptButton.template = 'ReprintReceiptButton';
+
+ OrderManagementScreen.addControlButton({
+ component: ReprintReceiptButton,
+ condition: function () {
+ return true;
+ },
+ });
+
+ Registries.Component.add(ReprintReceiptButton);
+
+ return ReprintReceiptButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js
new file mode 100644
index 00000000..b5766ccf
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js
@@ -0,0 +1,25 @@
+odoo.define('point_of_sale.MobileOrderManagementScreen', function (require) {
+ const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+ const { useState } = owl.hooks;
+
+ const MobileOrderManagementScreen = (OrderManagementScreen) => {
+ class MobileOrderManagementScreen extends OrderManagementScreen {
+ constructor() {
+ super(...arguments);
+ useListener('click-order', this._onShowDetails)
+ this.mobileState = useState({ showDetails: false });
+ }
+ _onShowDetails() {
+ this.mobileState.showDetails = true;
+ }
+ }
+ MobileOrderManagementScreen.template = 'MobileOrderManagementScreen';
+ return MobileOrderManagementScreen;
+ };
+
+ Registries.Component.addByExtending(MobileOrderManagementScreen, OrderManagementScreen);
+
+ return MobileOrderManagementScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js
new file mode 100644
index 00000000..cc0c671c
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js
@@ -0,0 +1,29 @@
+odoo.define('point_of_sale.OrderDetails', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props {models.Order} order
+ */
+ class OrderDetails extends PosComponent {
+ get order() {
+ return this.props.order;
+ }
+ get orderlines() {
+ return this.order ? this.order.orderlines.models : [];
+ }
+ get total() {
+ return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0);
+ }
+ get tax() {
+ return this.env.pos.format_currency(this.order ? this.order.get_total_tax() : 0)
+ }
+ }
+ OrderDetails.template = 'OrderDetails';
+
+ Registries.Component.add(OrderDetails);
+
+ return OrderDetails;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js
new file mode 100644
index 00000000..57a02635
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js
@@ -0,0 +1,214 @@
+odoo.define('point_of_sale.OrderFetcher', function (require) {
+ 'use strict';
+
+ const { EventBus } = owl.core;
+ const { Gui } = require('point_of_sale.Gui');
+ const { isRpcError } = require('point_of_sale.utils');
+ const models = require('point_of_sale.models');
+
+ class OrderFetcher extends EventBus {
+ constructor() {
+ super();
+ this.currentPage = 1;
+ this.ordersToShow = [];
+ this.cache = {};
+ this.totalCount = 0;
+ }
+ get activeOrders() {
+ const allActiveOrders = this.comp.env.pos.get('orders').models;
+ return this.searchDomain
+ ? allActiveOrders.filter(this._predicateBasedOnSearchDomain.bind(this))
+ : allActiveOrders;
+ }
+ _predicateBasedOnSearchDomain(order) {
+ function check(order, field, searchWord) {
+ searchWord = searchWord.toLowerCase();
+ switch (field) {
+ case 'pos_reference':
+ return order.name.toLowerCase().includes(searchWord);
+ case 'partner_id.display_name':
+ const client = order.get_client();
+ return client ? client.name.toLowerCase().includes(searchWord) : false;
+ case 'date_order':
+ return moment(order.creation_date).format('YYYY-MM-DD hh:mm A').includes(searchWord);
+ default:
+ return false;
+ }
+ }
+ for (let [field, _, searchWord] of (this.searchDomain || []).filter((item) => item !== '|')) {
+ // remove surrounding "%" from `searchWord`
+ searchWord = searchWord.substring(1, searchWord.length - 1);
+ if (check(order, field, searchWord)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ get nActiveOrders() {
+ return this.activeOrders.length;
+ }
+ get lastPageFullOfActiveOrders() {
+ return Math.trunc(this.nActiveOrders / this.nPerPage);
+ }
+ get remainingActiveOrders() {
+ return this.nActiveOrders % this.nPerPage;
+ }
+ /**
+ * for nPerPage = 10
+ * +--------+----------+
+ * | nItems | lastPage |
+ * +--------+----------+
+ * | 2 | 1 |
+ * | 10 | 1 |
+ * | 11 | 2 |
+ * | 30 | 3 |
+ * | 35 | 4 |
+ * +--------+----------+
+ */
+ get lastPage() {
+ const nItems = this.nActiveOrders + this.totalCount;
+ return Math.trunc(nItems / (this.nPerPage + 1)) + 1;
+ }
+ /**
+ * Calling this methods populates the `ordersToShow` then trigger `update` event.
+ * @related get
+ *
+ * NOTE: This is tightly-coupled with pagination. So if the current page contains all
+ * active orders, it will not fetch anything from the server but only sets `ordersToShow`
+ * to the active orders that fits the current page.
+ */
+ async fetch() {
+ try {
+ let limit, offset;
+ let start, end;
+ if (this.currentPage <= this.lastPageFullOfActiveOrders) {
+ // Show only active orders.
+ start = (this.currentPage - 1) * this.nPerPage;
+ end = this.currentPage * this.nPerPage;
+ this.ordersToShow = this.activeOrders.slice(start, end);
+ } else if (this.currentPage === this.lastPageFullOfActiveOrders + 1) {
+ // Show partially the remaining active orders and
+ // some orders from the backend.
+ offset = 0;
+ limit = this.nPerPage - this.remainingActiveOrders;
+ start = (this.currentPage - 1) * this.nPerPage;
+ end = this.nActiveOrders;
+ this.ordersToShow = [
+ ...this.activeOrders.slice(start, end),
+ ...(await this._fetch(limit, offset)),
+ ];
+ } else {
+ // Show orders from the backend.
+ offset =
+ this.nPerPage -
+ this.remainingActiveOrders +
+ (this.currentPage - (this.lastPageFullOfActiveOrders + 1) - 1) *
+ this.nPerPage;
+ limit = this.nPerPage;
+ this.ordersToShow = await this._fetch(limit, offset);
+ }
+ this.trigger('update');
+ } catch (error) {
+ if (isRpcError(error) && error.message.code < 0) {
+ Gui.showPopup('ErrorPopup', {
+ title: this.comp.env._t('Network Error'),
+ body: this.comp.env._t('Unable to fetch orders if offline.'),
+ });
+ Gui.setSyncStatus('error');
+ } else {
+ throw error;
+ }
+ }
+ }
+ /**
+ * This returns the orders from the backend that needs to be shown.
+ * If the order is already in cache, the full information about that
+ * order is not fetched anymore, instead, we use info from cache.
+ *
+ * @param {number} limit
+ * @param {number} offset
+ */
+ async _fetch(limit, offset) {
+ const { ids, totalCount } = await this._getOrderIdsForCurrentPage(limit, offset);
+ const idsNotInCache = ids.filter((id) => !(id in this.cache));
+ if (idsNotInCache.length > 0) {
+ const fetchedOrders = await this._fetchOrders(idsNotInCache);
+ // Cache these fetched orders so that next time, no need to fetch
+ // them again, unless invalidated. See `invalidateCache`.
+ fetchedOrders.forEach((order) => {
+ this.cache[order.id] = new models.Order(
+ {},
+ { pos: this.comp.env.pos, json: order }
+ );
+ });
+ }
+ this.totalCount = totalCount;
+ return ids.map((id) => this.cache[id]);
+ }
+ async _getOrderIdsForCurrentPage(limit, offset) {
+ return await this.rpc({
+ model: 'pos.order',
+ method: 'search_paid_order_ids',
+ kwargs: { config_id: this.configId, domain: this.searchDomain ? this.searchDomain : [], limit, offset },
+ context: this.comp.env.session.user_context,
+ });
+ }
+ async _fetchOrders(ids) {
+ return await this.rpc({
+ model: 'pos.order',
+ method: 'export_for_ui',
+ args: [ids],
+ context: this.comp.env.session.user_context,
+ });
+ }
+ nextPage() {
+ if (this.currentPage < this.lastPage) {
+ this.currentPage += 1;
+ this.fetch();
+ }
+ }
+ prevPage() {
+ if (this.currentPage > 1) {
+ this.currentPage -= 1;
+ this.fetch();
+ }
+ }
+ /**
+ * @param {integer|undefined} id id of the cached order
+ * @returns {Array<models.Order>}
+ */
+ get(id) {
+ if (id) return this.cache[id];
+ return this.ordersToShow;
+ }
+ setSearchDomain(searchDomain) {
+ this.searchDomain = searchDomain;
+ }
+ setComponent(comp) {
+ this.comp = comp;
+ return this;
+ }
+ setConfigId(configId) {
+ this.configId = configId;
+ }
+ setNPerPage(val) {
+ this.nPerPage = val;
+ }
+ setPage(page) {
+ this.currentPage = page;
+ }
+ invalidateCache(ids) {
+ for (let id of ids) {
+ delete this.cache[id];
+ }
+ }
+ async rpc() {
+ Gui.setSyncStatus('connecting');
+ const result = await this.comp.rpc(...arguments);
+ Gui.setSyncStatus('connected');
+ return result;
+ }
+ }
+
+ return new OrderFetcher();
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js
new file mode 100644
index 00000000..2b4d3cd9
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js
@@ -0,0 +1,31 @@
+odoo.define('point_of_sale.OrderList', function (require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props {models.Order} [initHighlightedOrder] initially highligted order
+ * @props {Array<models.Order>} orders
+ */
+ class OrderList extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click-order', this._onClickOrder);
+ this.state = useState({ highlightedOrder: this.props.initHighlightedOrder || null });
+ }
+ get highlightedOrder() {
+ return this.state.highlightedOrder;
+ }
+ _onClickOrder({ detail: order }) {
+ this.state.highlightedOrder = order;
+ }
+ }
+ OrderList.template = 'OrderList';
+
+ Registries.Component.add(OrderList);
+
+ return OrderList;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js
new file mode 100644
index 00000000..951a0956
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js
@@ -0,0 +1,124 @@
+odoo.define('point_of_sale.OrderManagementControlPanel', function (require) {
+ 'use strict';
+
+ const { useContext } = owl.hooks;
+ const { useAutofocus, useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const OrderFetcher = require('point_of_sale.OrderFetcher');
+ const contexts = require('point_of_sale.PosContext');
+
+ // NOTE: These are constants so that they are only instantiated once
+ // and they can be used efficiently by the OrderManagementControlPanel.
+ const VALID_SEARCH_TAGS = new Set(['date', 'customer', 'client', 'name', 'order']);
+ const FIELD_MAP = {
+ date: 'date_order',
+ customer: 'partner_id.display_name',
+ client: 'partner_id.display_name',
+ name: 'pos_reference',
+ order: 'pos_reference',
+ };
+ const SEARCH_FIELDS = ['pos_reference', 'partner_id.display_name', 'date_order'];
+
+ function getDomainForSingleCondition(fields, toSearch) {
+ const orSymbols = Array(fields.length - 1).fill('|');
+ return orSymbols.concat(fields.map((field) => [field, 'ilike', `%${toSearch}%`]));
+ }
+
+ /**
+ * @emits close-screen
+ * @emits prev-page
+ * @emits next-page
+ * @emits search
+ */
+ class OrderManagementControlPanel extends PosComponent {
+ constructor() {
+ super(...arguments);
+ // We are using context because we want the `searchString` to be alive
+ // even if this component is destroyed (unmounted).
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ useListener('clear-search', this._onClearSearch);
+ useAutofocus({ selector: 'input' });
+ }
+ onInputKeydown(event) {
+ if (event.key === 'Enter') {
+ this.trigger('search', this._computeDomain());
+ }
+ }
+ get showPageControls() {
+ return OrderFetcher.lastPage > 1;
+ }
+ get pageNumber() {
+ const currentPage = OrderFetcher.currentPage;
+ const lastPage = OrderFetcher.lastPage;
+ return isNaN(lastPage) ? '' : `(${currentPage}/${lastPage})`;
+ }
+ get validSearchTags() {
+ return VALID_SEARCH_TAGS;
+ }
+ get fieldMap() {
+ return FIELD_MAP;
+ }
+ get searchFields() {
+ return SEARCH_FIELDS;
+ }
+ /**
+ * E.g. 1
+ * ```
+ * searchString = 'Customer 1'
+ * result = [
+ * '|',
+ * '|',
+ * ['pos_reference', 'ilike', '%Customer 1%'],
+ * ['partner_id.display_name', 'ilike', '%Customer 1%'],
+ * ['date_order', 'ilike', '%Customer 1%']
+ * ]
+ * ```
+ *
+ * E.g. 2
+ * ```
+ * searchString = 'date: 2020-05'
+ * result = [
+ * ['date_order', 'ilike', '%2020-05%']
+ * ]
+ * ```
+ *
+ * E.g. 3
+ * ```
+ * searchString = 'customer: Steward, date: 2020-05-01'
+ * result = [
+ * ['partner_id.display_name', 'ilike', '%Steward%'],
+ * ['date_order', 'ilike', '%2020-05-01%']
+ * ]
+ * ```
+ */
+ _computeDomain() {
+ const input = this.orderManagementContext.searchString.trim();
+ if (!input) return;
+
+ const searchConditions = this.orderManagementContext.searchString.split(/[,&]\s*/);
+ if (searchConditions.length === 1) {
+ let cond = searchConditions[0].split(/:\s*/);
+ if (cond.length === 1) {
+ return getDomainForSingleCondition(this.searchFields, cond[0]);
+ }
+ }
+ const domain = [];
+ for (let cond of searchConditions) {
+ let [tag, value] = cond.split(/:\s*/);
+ if (!this.validSearchTags.has(tag)) continue;
+ domain.push([this.fieldMap[tag], 'ilike', `%${value}%`]);
+ }
+ return domain;
+ }
+ _onClearSearch() {
+ this.orderManagementContext.searchString = '';
+ this.onInputKeydown({ key: 'Enter' });
+ }
+ }
+ OrderManagementControlPanel.template = 'OrderManagementControlPanel';
+
+ Registries.Component.add(OrderManagementControlPanel);
+
+ return OrderManagementControlPanel;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js
new file mode 100644
index 00000000..dcde9739
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js
@@ -0,0 +1,101 @@
+odoo.define('point_of_sale.OrderManagementScreen', function (require) {
+ 'use strict';
+
+ const { useContext } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const Registries = require('point_of_sale.Registries');
+ const OrderFetcher = require('point_of_sale.OrderFetcher');
+ const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
+ const contexts = require('point_of_sale.PosContext');
+
+ class OrderManagementScreen extends ControlButtonsMixin(IndependentToOrderScreen) {
+ constructor() {
+ super(...arguments);
+ useListener('close-screen', this.close);
+ useListener('set-numpad-mode', this._setNumpadMode);
+ useListener('click-order', this._onClickOrder);
+ useListener('next-page', this._onNextPage);
+ useListener('prev-page', this._onPrevPage);
+ useListener('search', this._onSearch);
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ useWithBarcode: true,
+ });
+ this.numpadMode = 'quantity';
+ OrderFetcher.setComponent(this);
+ OrderFetcher.setConfigId(this.env.pos.config_id);
+ this.orderManagementContext = useContext(contexts.orderManagement);
+ }
+ mounted() {
+ OrderFetcher.on('update', this, this.render);
+ this.env.pos.get('orders').on('add remove', this.render, this);
+
+ // calculate how many can fit in the screen.
+ // It is based on the height of the header element.
+ // So the result is only accurate if each row is just single line.
+ const flexContainer = this.el.querySelector('.flex-container');
+ const cpEl = this.el.querySelector('.control-panel');
+ const headerEl = this.el.querySelector('.order-row.header');
+ const val = Math.trunc(
+ (flexContainer.offsetHeight - cpEl.offsetHeight - headerEl.offsetHeight) /
+ headerEl.offsetHeight
+ );
+ OrderFetcher.setNPerPage(val);
+
+ // Fetch the order after mounting so that order management screen
+ // is shown while fetching.
+ setTimeout(() => OrderFetcher.fetch(), 0);
+ }
+ willUnmount() {
+ OrderFetcher.off('update', this);
+ this.env.pos.get('orders').off('add remove', null, this);
+ }
+ get selectedClient() {
+ const order = this.orderManagementContext.selectedOrder;
+ return order ? order.get_client() : null;
+ }
+ get orders() {
+ return OrderFetcher.get();
+ }
+ async _setNumpadMode(event) {
+ const { mode } = event.detail;
+ this.numpadMode = mode;
+ NumberBuffer.reset();
+ }
+ _onNextPage() {
+ OrderFetcher.nextPage();
+ }
+ _onPrevPage() {
+ OrderFetcher.prevPage();
+ }
+ _onSearch({ detail: domain }) {
+ OrderFetcher.setSearchDomain(domain);
+ OrderFetcher.setPage(1);
+ OrderFetcher.fetch();
+ }
+ _onClickOrder({ detail: clickedOrder }) {
+ if (!clickedOrder || clickedOrder.locked) {
+ this.orderManagementContext.selectedOrder = clickedOrder;
+ } else {
+ this._setOrder(clickedOrder);
+ }
+ }
+ /**
+ * @param {models.Order} order
+ */
+ _setOrder(order) {
+ this.env.pos.set_order(order);
+ if (order === this.env.pos.get_order()) {
+ this.close();
+ }
+ }
+ }
+ OrderManagementScreen.template = 'OrderManagementScreen';
+ OrderManagementScreen.hideOrderSelector = true;
+
+ Registries.Component.add(OrderManagementScreen);
+
+ return OrderManagementScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js
new file mode 100644
index 00000000..959ea5a1
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.OrderRow', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props {models.Order} order
+ * @props columns
+ * @emits click-order
+ */
+ class OrderRow extends PosComponent {
+ get order() {
+ return this.props.order;
+ }
+ get highlighted() {
+ const highlightedOrder = this.props.highlightedOrder;
+ return !highlightedOrder ? false : highlightedOrder.backendId === this.props.order.backendId;
+ }
+
+ // Column getters //
+
+ get name() {
+ return this.order.get_name();
+ }
+ get date() {
+ return moment(this.order.validation_date).format('YYYY-MM-DD hh:mm A');
+ }
+ get customer() {
+ const customer = this.order.get('client');
+ return customer ? customer.name : null;
+ }
+ get total() {
+ return this.env.pos.format_currency(this.order.get_total_with_tax());
+ }
+ }
+ OrderRow.template = 'OrderRow';
+
+ Registries.Component.add(OrderRow);
+
+ return OrderRow;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
new file mode 100644
index 00000000..35f6ec5d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
@@ -0,0 +1,55 @@
+odoo.define('point_of_sale.OrderlineDetails', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { format } = require('web.field_utils');
+ const { round_precision: round_pr } = require('web.utils');
+
+ /**
+ * @props {pos.order.line} line
+ */
+ class OrderlineDetails extends PosComponent {
+ get line() {
+ const line = this.props.line;
+ const formatQty = (line) => {
+ const quantity = line.get_quantity();
+ const unit = line.get_unit();
+ const decimals = this.env.pos.dp['Product Unit of Measure'];
+ const rounding = Math.max(unit.rounding, Math.pow(10, -decimals));
+ const roundedQuantity = round_pr(quantity, rounding);
+ return format.float(roundedQuantity, { digits: [69, decimals] });
+ };
+ return {
+ productName: line.get_full_product_name(),
+ totalPrice: line.get_price_with_tax(),
+ quantity: formatQty(line),
+ unit: line.get_unit().name,
+ unitPrice: line.get_unit_price(),
+ };
+ }
+ get productName() {
+ return this.line.productName;
+ }
+ get totalPrice() {
+ return this.env.pos.format_currency(this.line.totalPrice);
+ }
+ get quantity() {
+ return this.line.quantity;
+ }
+ get unitPrice() {
+ return this.line.unitPrice;
+ }
+ get unit() {
+ return this.line.unit;
+ }
+ get pricePerUnit() {
+ return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`;
+ }
+ }
+ OrderlineDetails.template = 'OrderlineDetails';
+
+ Registries.Component.add(OrderlineDetails);
+
+ return OrderlineDetails;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js
new file mode 100644
index 00000000..7fcc514d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js
@@ -0,0 +1,32 @@
+odoo.define('point_of_sale.ReprintReceiptScreen', function (require) {
+ 'use strict';
+
+ const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
+ const Registries = require('point_of_sale.Registries');
+
+ const ReprintReceiptScreen = (AbstractReceiptScreen) => {
+ class ReprintReceiptScreen extends AbstractReceiptScreen {
+ mounted() {
+ this.printReceipt();
+ }
+ confirm() {
+ this.showScreen('OrderManagementScreen');
+ }
+ async printReceipt() {
+ if(this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen) {
+ let result = await this._printReceipt();
+ if(result)
+ this.showScreen('OrderManagementScreen');
+ }
+ }
+ async tryReprint() {
+ await this._printReceipt();
+ }
+ }
+ ReprintReceiptScreen.template = 'ReprintReceiptScreen';
+ return ReprintReceiptScreen;
+ };
+ Registries.Component.addByExtending(ReprintReceiptScreen, AbstractReceiptScreen);
+
+ return ReprintReceiptScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js
new file mode 100644
index 00000000..b5dc6a7b
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js
@@ -0,0 +1,17 @@
+odoo.define('point_of_sale.PSNumpadInputButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PSNumpadInputButton extends PosComponent {
+ get _class() {
+ return this.props.changeClassTo || 'input-button number-char';
+ }
+ }
+ PSNumpadInputButton.template = 'PSNumpadInputButton';
+
+ Registries.Component.add(PSNumpadInputButton);
+
+ return PSNumpadInputButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js
new file mode 100644
index 00000000..8e5853d3
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.PaymentMethodButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentMethodButton extends PosComponent {}
+ PaymentMethodButton.template = 'PaymentMethodButton';
+
+ Registries.Component.add(PaymentMethodButton);
+
+ return PaymentMethodButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js
new file mode 100644
index 00000000..6fe25a11
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js
@@ -0,0 +1,376 @@
+odoo.define('point_of_sale.PaymentScreen', function (require) {
+ 'use strict';
+
+ const { parse } = require('web.field_utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useErrorHandlers } = require('point_of_sale.custom_hooks');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+ const { onChangeOrder } = require('point_of_sale.custom_hooks');
+
+ class PaymentScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('delete-payment-line', this.deletePaymentLine);
+ useListener('select-payment-line', this.selectPaymentLine);
+ useListener('new-payment-line', this.addNewPaymentLine);
+ useListener('update-selected-paymentline', this._updateSelectedPaymentline);
+ useListener('send-payment-request', this._sendPaymentRequest);
+ useListener('send-payment-cancel', this._sendPaymentCancel);
+ useListener('send-payment-reverse', this._sendPaymentReverse);
+ useListener('send-force-done', this._sendForceDone);
+ NumberBuffer.use({
+ // The numberBuffer listens to this event to update its state.
+ // Basically means 'update the buffer when this event is triggered'
+ nonKeyboardInputEvent: 'input-from-numpad',
+ // When the buffer is updated, trigger this event.
+ // Note that the component listens to it.
+ triggerAtInput: 'update-selected-paymentline',
+ });
+ onChangeOrder(this._onPrevOrder, this._onNewOrder);
+ useErrorHandlers();
+ this.payment_interface = null;
+ this.error = false;
+ this.payment_methods_from_config = this.env.pos.payment_methods.filter(method => this.env.pos.config.payment_method_ids.includes(method.id));
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get paymentLines() {
+ return this.currentOrder.get_paymentlines();
+ }
+ get selectedPaymentLine() {
+ return this.currentOrder.selected_paymentline;
+ }
+ async selectClient() {
+ // IMPROVEMENT: This code snippet is repeated multiple times.
+ // Maybe it's better to create a function for it.
+ const currentClient = this.currentOrder.get_client();
+ const { confirmed, payload: newClient } = await this.showTempScreen(
+ 'ClientListScreen',
+ { client: currentClient }
+ );
+ if (confirmed) {
+ this.currentOrder.set_client(newClient);
+ this.currentOrder.updatePricelist(newClient);
+ }
+ }
+ addNewPaymentLine({ detail: paymentMethod }) {
+ // original function: click_paymentmethods
+ if (this.currentOrder.electronic_payment_in_progress()) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Error'),
+ body: this.env._t('There is already an electronic payment in progress.'),
+ });
+ return false;
+ } else {
+ this.currentOrder.add_paymentline(paymentMethod);
+ NumberBuffer.reset();
+ this.payment_interface = paymentMethod.payment_terminal;
+ if (this.payment_interface) {
+ this.currentOrder.selected_paymentline.set_payment_status('pending');
+ }
+ return true;
+ }
+ }
+ _updateSelectedPaymentline() {
+ if (this.paymentLines.every((line) => line.paid)) {
+ this.currentOrder.add_paymentline(this.payment_methods_from_config[0]);
+ }
+ if (!this.selectedPaymentLine) return; // do nothing if no selected payment line
+ // disable changing amount on paymentlines with running or done payments on a payment terminal
+ if (
+ this.payment_interface &&
+ !['pending', 'retry'].includes(this.selectedPaymentLine.get_payment_status())
+ ) {
+ return;
+ }
+ if (NumberBuffer.get() === null) {
+ this.deletePaymentLine({ detail: { cid: this.selectedPaymentLine.cid } });
+ } else {
+ this.selectedPaymentLine.set_amount(NumberBuffer.getFloat());
+ }
+ }
+ toggleIsToInvoice() {
+ // click_invoice
+ this.currentOrder.set_to_invoice(!this.currentOrder.is_to_invoice());
+ this.render();
+ }
+ openCashbox() {
+ this.env.pos.proxy.printer.open_cashbox();
+ }
+ async addTip() {
+ // click_tip
+ const tip = this.currentOrder.get_tip();
+ const change = this.currentOrder.get_change();
+ let value = tip.toFixed(this.env.pos.decimals);
+
+ if (tip === 0 && change > 0) {
+ value = change;
+ }
+
+ const { confirmed, payload } = await this.showPopup('NumberPopup', {
+ title: tip ? this.env._t('Change Tip') : this.env._t('Add Tip'),
+ startingValue: value,
+ });
+
+ if (confirmed) {
+ this.currentOrder.set_tip(parse.float(payload));
+ }
+ }
+ deletePaymentLine(event) {
+ const { cid } = event.detail;
+ const line = this.paymentLines.find((line) => line.cid === cid);
+
+ // If a paymentline with a payment terminal linked to
+ // it is removed, the terminal should get a cancel
+ // request.
+ if (['waiting', 'waitingCard', 'timeout'].includes(line.get_payment_status())) {
+ line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid);
+ }
+
+ this.currentOrder.remove_paymentline(line);
+ NumberBuffer.reset();
+ this.render();
+ }
+ selectPaymentLine(event) {
+ const { cid } = event.detail;
+ const line = this.paymentLines.find((line) => line.cid === cid);
+ this.currentOrder.select_paymentline(line);
+ NumberBuffer.reset();
+ this.render();
+ }
+ async validateOrder(isForceValidate) {
+ if(this.env.pos.config.cash_rounding) {
+ if(!this.env.pos.get_order().check_paymentlines_rounding()) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Rounding error in payment lines'),
+ body: this.env._t("The amount of your payment lines must be rounded to validate the transaction."),
+ });
+ return;
+ }
+ }
+ if (await this._isOrderValid(isForceValidate)) {
+ // remove pending payments before finalizing the validation
+ for (let line of this.paymentLines) {
+ if (!line.is_done()) this.currentOrder.remove_paymentline(line);
+ }
+ await this._finalizeValidation();
+ }
+ }
+ async _finalizeValidation() {
+ if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.env.pos.config.iface_cashdrawer) {
+ this.env.pos.proxy.printer.open_cashbox();
+ }
+
+ this.currentOrder.initialize_validation_date();
+ this.currentOrder.finalized = true;
+
+ let syncedOrderBackendIds = [];
+
+ try {
+ if (this.currentOrder.is_to_invoice()) {
+ syncedOrderBackendIds = await this.env.pos.push_and_invoice_order(
+ this.currentOrder
+ );
+ } else {
+ syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder);
+ }
+ } catch (error) {
+ if (error.code == 700)
+ this.error = true;
+ if (error instanceof Error) {
+ throw error;
+ } else {
+ await this._handlePushOrderError(error);
+ }
+ }
+ if (syncedOrderBackendIds.length && this.currentOrder.wait_for_push_order()) {
+ const result = await this._postPushOrderResolve(
+ this.currentOrder,
+ syncedOrderBackendIds
+ );
+ if (!result) {
+ await this.showPopup('ErrorPopup', {
+ title: 'Error: no internet connection.',
+ body: error,
+ });
+ }
+ }
+
+ this.showScreen(this.nextScreen);
+
+ // If we succeeded in syncing the current order, and
+ // there are still other orders that are left unsynced,
+ // we ask the user if he is willing to wait and sync them.
+ if (syncedOrderBackendIds.length && this.env.pos.db.get_orders().length) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Remaining unsynced orders'),
+ body: this.env._t(
+ 'There are unsynced orders. Do you want to sync these orders?'
+ ),
+ });
+ if (confirmed) {
+ // NOTE: Not yet sure if this should be awaited or not.
+ // If awaited, some operations like changing screen
+ // might not work.
+ this.env.pos.push_orders();
+ }
+ }
+ }
+ get nextScreen() {
+ return !this.error? 'ReceiptScreen' : 'ProductScreen';
+ }
+ async _isOrderValid(isForceValidate) {
+ if (this.currentOrder.get_orderlines().length === 0) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Empty Order'),
+ body: this.env._t(
+ 'There must be at least one product in your order before it can be validated'
+ ),
+ });
+ return false;
+ }
+
+ if (this.currentOrder.is_to_invoice() && !this.currentOrder.get_client()) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: this.env._t('Please select the Customer'),
+ body: this.env._t(
+ 'You need to select the customer before you can invoice an order.'
+ ),
+ });
+ if (confirmed) {
+ this.selectClient();
+ }
+ return false;
+ }
+
+ if (!this.currentOrder.is_paid() || this.invoicing) {
+ return false;
+ }
+
+ if (this.currentOrder.has_not_valid_rounding()) {
+ var line = this.currentOrder.has_not_valid_rounding();
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Incorrect rounding'),
+ body: this.env._t(
+ 'You have to round your payments lines.' + line.amount + ' is not rounded.'
+ ),
+ });
+ return false;
+ }
+
+ // The exact amount must be paid if there is no cash payment method defined.
+ if (
+ Math.abs(
+ this.currentOrder.get_total_with_tax() - this.currentOrder.get_total_paid() + this.currentOrder.get_rounding_applied()
+ ) > 0.00001
+ ) {
+ var cash = false;
+ for (var i = 0; i < this.env.pos.payment_methods.length; i++) {
+ cash = cash || this.env.pos.payment_methods[i].is_cash_count;
+ }
+ if (!cash) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Cannot return change without a cash payment method'),
+ body: this.env._t(
+ 'There is no cash payment method available in this point of sale to handle the change.\n\n Please pay the exact amount or add a cash payment method in the point of sale configuration'
+ ),
+ });
+ return false;
+ }
+ }
+
+ // if the change is too large, it's probably an input error, make the user confirm.
+ if (
+ !isForceValidate &&
+ this.currentOrder.get_total_with_tax() > 0 &&
+ this.currentOrder.get_total_with_tax() * 1000 < this.currentOrder.get_total_paid()
+ ) {
+ this.showPopup('ConfirmPopup', {
+ title: this.env._t('Please Confirm Large Amount'),
+ body:
+ this.env._t('Are you sure that the customer wants to pay') +
+ ' ' +
+ this.env.pos.format_currency(this.currentOrder.get_total_paid()) +
+ ' ' +
+ this.env._t('for an order of') +
+ ' ' +
+ this.env.pos.format_currency(this.currentOrder.get_total_with_tax()) +
+ ' ' +
+ this.env._t('? Clicking "Confirm" will validate the payment.'),
+ }).then(({ confirmed }) => {
+ if (confirmed) this.validateOrder(true);
+ });
+ return false;
+ }
+
+ return true;
+ }
+ async _postPushOrderResolve(order, order_server_ids) {
+ return true;
+ }
+ async _sendPaymentRequest({ detail: line }) {
+ // Other payment lines can not be reversed anymore
+ this.paymentLines.forEach(function (line) {
+ line.can_be_reversed = false;
+ });
+
+ const payment_terminal = line.payment_method.payment_terminal;
+ line.set_payment_status('waiting');
+
+ const isPaymentSuccessful = await payment_terminal.send_payment_request(line.cid);
+ if (isPaymentSuccessful) {
+ line.set_payment_status('done');
+ line.can_be_reversed = this.payment_interface.supports_reversals;
+ } else {
+ line.set_payment_status('retry');
+ }
+ }
+ async _sendPaymentCancel({ detail: line }) {
+ const payment_terminal = line.payment_method.payment_terminal;
+ line.set_payment_status('waitingCancel');
+ const isCancelSuccessful = await payment_terminal.send_payment_cancel(this.currentOrder, line.cid);
+ if (isCancelSuccessful) {
+ line.set_payment_status('retry');
+ } else {
+ line.set_payment_status('waitingCard');
+ }
+ }
+ async _sendPaymentReverse({ detail: line }) {
+ const payment_terminal = line.payment_method.payment_terminal;
+ line.set_payment_status('reversing');
+
+ const isReversalSuccessful = await payment_terminal.send_payment_reversal(line.cid);
+ if (isReversalSuccessful) {
+ line.set_amount(0);
+ line.set_payment_status('reversed');
+ } else {
+ line.can_be_reversed = false;
+ line.set_payment_status('done');
+ }
+ }
+ async _sendForceDone({ detail: line }) {
+ line.set_payment_status('done');
+ }
+ _onPrevOrder(prevOrder) {
+ prevOrder.off('change', null, this);
+ prevOrder.paymentlines.off('change', null, this);
+ if (prevOrder) {
+ prevOrder.stop_electronic_payment();
+ }
+ }
+ async _onNewOrder(newOrder) {
+ newOrder.on('change', this.render, this);
+ newOrder.paymentlines.on('change', this.render, this);
+ NumberBuffer.reset();
+ await this.render();
+ }
+ }
+ PaymentScreen.template = 'PaymentScreen';
+
+ Registries.Component.add(PaymentScreen);
+
+ return PaymentScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js
new file mode 100644
index 00000000..6cafac15
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.PaymentScreenElectronicPayment', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenElectronicPayment extends PosComponent {
+ mounted() {
+ this.props.line.on('change', this.render, this);
+ }
+ willUnmount() {
+ if (this.props.line) {
+ // It could be that the line is deleted before unmounting the element.
+ this.props.line.off('change', null, this);
+ }
+ }
+ }
+ PaymentScreenElectronicPayment.template = 'PaymentScreenElectronicPayment';
+
+ Registries.Component.add(PaymentScreenElectronicPayment);
+
+ return PaymentScreenElectronicPayment;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js
new file mode 100644
index 00000000..e661722f
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js
@@ -0,0 +1,18 @@
+odoo.define('point_of_sale.PaymentScreenNumpad', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenNumpad extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.decimalPoint = this.env._t.database.parameters.decimal_point;
+ }
+ }
+ PaymentScreenNumpad.template = 'PaymentScreenNumpad';
+
+ Registries.Component.add(PaymentScreenNumpad);
+
+ return PaymentScreenNumpad;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js
new file mode 100644
index 00000000..8f231146
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.PaymentScreenPaymentLines', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenPaymentLines extends PosComponent {
+ formatLineAmount(paymentline) {
+ return this.env.pos.format_currency_no_symbol(paymentline.get_amount());
+ }
+ selectedLineClass(line) {
+ return { 'payment-terminal': line.get_payment_status() };
+ }
+ unselectedLineClass(line) {
+ return {};
+ }
+ }
+ PaymentScreenPaymentLines.template = 'PaymentScreenPaymentLines';
+
+ Registries.Component.add(PaymentScreenPaymentLines);
+
+ return PaymentScreenPaymentLines;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js
new file mode 100644
index 00000000..12ccaa84
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js
@@ -0,0 +1,30 @@
+odoo.define('point_of_sale.PaymentScreenStatus', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class PaymentScreenStatus extends PosComponent {
+ get changeText() {
+ return this.env.pos.format_currency(this.currentOrder.get_change());
+ }
+ get totalDueText() {
+ return this.env.pos.format_currency(
+ this.currentOrder.get_total_with_tax() + this.currentOrder.get_rounding_applied()
+ );
+ }
+ get remainingText() {
+ return this.env.pos.format_currency(
+ this.currentOrder.get_due() > 0 ? this.currentOrder.get_due() : 0
+ );
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ }
+ PaymentScreenStatus.template = 'PaymentScreenStatus';
+
+ Registries.Component.add(PaymentScreenStatus);
+
+ return PaymentScreenStatus;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js
new file mode 100644
index 00000000..d30fe85e
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js
@@ -0,0 +1,25 @@
+odoo.define('point_of_sale.ActionpadWidget', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @props client
+ * @emits click-customer
+ * @emits click-pay
+ */
+ class ActionpadWidget extends PosComponent {
+ get isLongName() {
+ return this.client && this.client.name.length > 10;
+ }
+ get client() {
+ return this.props.client;
+ }
+ }
+ ActionpadWidget.template = 'ActionpadWidget';
+
+ Registries.Component.add(ActionpadWidget);
+
+ return ActionpadWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js
new file mode 100644
index 00000000..be42e45d
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.CashBoxOpening', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { Gui } = require('point_of_sale.Gui');
+
+ class CashBoxOpening extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.changes = {};
+ this.defaultValue = this.env.pos.bank_statement.balance_start || 0;
+ this.symbol = this.env.pos.currency.symbol;
+ }
+ captureChange(event) {
+ this.changes[event.target.name] = event.target.value;
+ }
+ startSession() {
+ let cashOpening = this.changes.cashBoxValue? this.changes.cashBoxValue: this.defaultValue;
+ if(isNaN(cashOpening)) {
+ Gui.showPopup('ErrorPopup',{
+ 'title': 'Wrong value',
+ 'body': 'Please insert a correct value.',
+ });
+ return;
+ }
+ this.env.pos.bank_statement.balance_start = cashOpening;
+ this.env.pos.pos_session.state = 'opened';
+ this.props.cashControl.cashControl = false;
+ this.rpc({
+ model: 'pos.session',
+ method: 'set_cashbox_pos',
+ args: [this.env.pos.pos_session.id, cashOpening, this.changes.notes],
+ });
+ }
+ }
+ CashBoxOpening.template = 'CashBoxOpening';
+
+ Registries.Component.add(CashBoxOpening);
+
+ return CashBoxOpening;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js
new file mode 100644
index 00000000..843cc248
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.CategoryBreadcrumb', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class CategoryBreadcrumb extends PosComponent {}
+ CategoryBreadcrumb.template = 'CategoryBreadcrumb';
+
+ Registries.Component.add(CategoryBreadcrumb);
+
+ return CategoryBreadcrumb;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
new file mode 100644
index 00000000..05914bec
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js
@@ -0,0 +1,18 @@
+odoo.define('point_of_sale.CategoryButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class CategoryButton extends PosComponent {
+ get imageUrl() {
+ const category = this.props.category
+ return `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
+ }
+ }
+ CategoryButton.template = 'CategoryButton';
+
+ Registries.Component.add(CategoryButton);
+
+ return CategoryButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js
new file mode 100644
index 00000000..675512d8
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.CategorySimpleButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class CategorySimpleButton extends PosComponent {}
+ CategorySimpleButton.template = 'CategorySimpleButton';
+
+ Registries.Component.add(CategorySimpleButton);
+
+ return CategorySimpleButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js
new file mode 100644
index 00000000..901e70e7
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js
@@ -0,0 +1,80 @@
+odoo.define('point_of_sale.SetFiscalPositionButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ProductScreen = require('point_of_sale.ProductScreen');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ class SetFiscalPositionButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this.onClick);
+ }
+ mounted() {
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get currentFiscalPositionName() {
+ return this.currentOrder && this.currentOrder.fiscal_position
+ ? this.currentOrder.fiscal_position.display_name
+ : this.env._t('Tax');
+ }
+ async onClick() {
+ const currentFiscalPosition = this.currentOrder.fiscal_position;
+ const fiscalPosList = [
+ {
+ id: -1,
+ label: this.env._t('None'),
+ isSelected: !currentFiscalPosition,
+ },
+ ];
+ for (let fiscalPos of this.env.pos.fiscal_positions) {
+ fiscalPosList.push({
+ id: fiscalPos.id,
+ label: fiscalPos.name,
+ isSelected: currentFiscalPosition
+ ? fiscalPos.id === currentFiscalPosition.id
+ : false,
+ item: fiscalPos,
+ });
+ }
+ const { confirmed, payload: selectedFiscalPosition } = await this.showPopup(
+ 'SelectionPopup',
+ {
+ title: this.env._t('Select Fiscal Position'),
+ list: fiscalPosList,
+ }
+ );
+ if (confirmed) {
+ this.currentOrder.fiscal_position = selectedFiscalPosition;
+ // IMPROVEMENT: The following is the old implementation and I believe
+ // there could be a better way of doing it.
+ for (let line of this.currentOrder.orderlines.models) {
+ line.set_quantity(line.quantity);
+ }
+ this.currentOrder.trigger('change');
+ }
+ }
+ }
+ SetFiscalPositionButton.template = 'SetFiscalPositionButton';
+
+ ProductScreen.addControlButton({
+ component: SetFiscalPositionButton,
+ condition: function() {
+ return this.env.pos.fiscal_positions.length > 0;
+ },
+ position: ['before', 'SetPricelistButton'],
+ });
+
+ Registries.Component.add(SetFiscalPositionButton);
+
+ return SetFiscalPositionButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js
new file mode 100644
index 00000000..c0a01f87
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js
@@ -0,0 +1,67 @@
+odoo.define('point_of_sale.SetPricelistButton', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ProductScreen = require('point_of_sale.ProductScreen');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ class SetPricelistButton extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('click', this.onClick);
+ }
+ mounted() {
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get currentPricelistName() {
+ const order = this.currentOrder;
+ return order && order.pricelist
+ ? order.pricelist.display_name
+ : this.env._t('Pricelist');
+ }
+ async onClick() {
+ // Create the list to be passed to the SelectionPopup.
+ // Pricelist object is passed as item in the list because it
+ // is the object that will be returned when the popup is confirmed.
+ const selectionList = this.env.pos.pricelists.map(pricelist => ({
+ id: pricelist.id,
+ label: pricelist.name,
+ isSelected: pricelist.id === this.currentOrder.pricelist.id,
+ item: pricelist,
+ }));
+
+ const { confirmed, payload: selectedPricelist } = await this.showPopup(
+ 'SelectionPopup',
+ {
+ title: this.env._t('Select the pricelist'),
+ list: selectionList,
+ }
+ );
+
+ if (confirmed) {
+ this.currentOrder.set_pricelist(selectedPricelist);
+ }
+ }
+ }
+ SetPricelistButton.template = 'SetPricelistButton';
+
+ ProductScreen.addControlButton({
+ component: SetPricelistButton,
+ condition: function() {
+ return this.env.pos.config.use_pricelist && this.env.pos.pricelists.length > 1;
+ },
+ });
+
+ Registries.Component.add(SetPricelistButton);
+
+ return SetPricelistButton;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js
new file mode 100644
index 00000000..28641236
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js
@@ -0,0 +1,47 @@
+odoo.define('point_of_sale.HomeCategoryBreadcrumb', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+
+ class HomeCategoryBreadcrumb extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('categ-popup', this._categPopup);
+ }
+ get selectedCategoryId() {
+ return this.env.pos.get('selectedCategoryId');
+ }
+ async _categPopup() {
+ let selectionList = [{
+ id: 0,
+ label:'All Items',
+ isSelected: 0 === this.env.pos.get('selectedCategoryId'),
+ item: {id:0,name:'All Items'},
+ }];
+ let subs = this.props.subcategories.map(category => ({
+ id: category.id,
+ label: category.name,
+ isSelected: category.id === this.env.pos.get('selectedCategoryId'),
+ item: category,
+ }));
+ selectionList = selectionList.concat(subs);
+ const { confirmed, payload: selectedCategory } = await this.showPopup(
+ 'SelectionPopup',
+ {
+ title: this.env._t('Select the category'),
+ list: selectionList,
+ }
+ );
+ if (confirmed) {
+ this.trigger('switch-category', selectedCategory.id);
+ }
+ }
+ }
+ HomeCategoryBreadcrumb.template = 'HomeCategoryBreadcrumb';
+
+ Registries.Component.add(HomeCategoryBreadcrumb);
+
+ return HomeCategoryBreadcrumb;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js
new file mode 100644
index 00000000..5850dc83
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js
@@ -0,0 +1,59 @@
+odoo.define('point_of_sale.NumpadWidget', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * @prop {'quantiy' | 'price' | 'discount'} activeMode
+ * @event set-numpad-mode - triggered when mode button is clicked
+ * @event numpad-click-input - triggered when numpad button is clicked
+ *
+ * IMPROVEMENT: Whenever new-orderline-selected is triggered,
+ * numpad mode should be set to 'quantity'. Now that the mode state
+ * is lifted to the parent component, this improvement can be done in
+ * the parent component.
+ */
+ class NumpadWidget extends PosComponent {
+ mounted() {
+ // IMPROVEMENT: This listener shouldn't be here because in core point_of_sale
+ // there is no way of changing the cashier. Only when pos_hr is installed
+ // that this listener makes sense.
+ this.env.pos.on('change:cashier', () => {
+ if (!this.hasPriceControlRights && this.props.activeMode === 'price') {
+ this.trigger('set-numpad-mode', { mode: 'quantity' });
+ }
+ });
+ }
+ willUnmount() {
+ this.env.pos.on('change:cashier', null, this);
+ }
+ get hasPriceControlRights() {
+ const cashier = this.env.pos.get('cashier') || this.env.pos.get_cashier();
+ return !this.env.pos.config.restrict_price_control || cashier.role == 'manager';
+ }
+ get hasManualDiscount() {
+ return this.env.pos.config.manual_discount;
+ }
+ changeMode(mode) {
+ if (!this.hasPriceControlRights && mode === 'price') {
+ return;
+ }
+ if (!this.hasManualDiscount && mode === 'discount') {
+ return;
+ }
+ this.trigger('set-numpad-mode', { mode });
+ }
+ sendInput(key) {
+ this.trigger('numpad-click-input', { key });
+ }
+ get decimalSeparator() {
+ return this.env._t.database.parameters.decimal_point;
+ }
+ }
+ NumpadWidget.template = 'NumpadWidget';
+
+ Registries.Component.add(NumpadWidget);
+
+ return NumpadWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js
new file mode 100644
index 00000000..aeb9891f
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.OrderSummary', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class OrderSummary extends PosComponent {}
+ OrderSummary.template = 'OrderSummary';
+
+ Registries.Component.add(OrderSummary);
+
+ return OrderSummary;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js
new file mode 100644
index 00000000..ee610afd
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js
@@ -0,0 +1,110 @@
+odoo.define('point_of_sale.OrderWidget', function(require) {
+ 'use strict';
+
+ const { useState, useRef, onPatched } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const { onChangeOrder } = require('point_of_sale.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class OrderWidget extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('select-line', this._selectLine);
+ useListener('edit-pack-lot-lines', this._editPackLotLines);
+ onChangeOrder(this._onPrevOrder, this._onNewOrder);
+ this.scrollableRef = useRef('scrollable');
+ this.scrollToBottom = false;
+ onPatched(() => {
+ // IMPROVEMENT
+ // This one just stays at the bottom of the orderlines list.
+ // Perhaps it is better to scroll to the added or modified orderline.
+ if (this.scrollToBottom) {
+ this.scrollableRef.el.scrollTop = this.scrollableRef.el.scrollHeight;
+ this.scrollToBottom = false;
+ }
+ });
+ this.state = useState({ total: 0, tax: 0 });
+ this._updateSummary();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ get orderlinesArray() {
+ return this.order ? this.order.get_orderlines() : [];
+ }
+ _selectLine(event) {
+ this.order.select_orderline(event.detail.orderline);
+ }
+ // IMPROVEMENT: Might be better to lift this to ProductScreen
+ // because there is similar operation when clicking a product.
+ //
+ // Furthermore, what if a number different from 1 (or -1) is specified
+ // to an orderline that has product tracked by lot. Lot tracking (based
+ // on the current implementation) requires that 1 item per orderline is
+ // allowed.
+ async _editPackLotLines(event) {
+ const orderline = event.detail.orderline;
+ const isAllowOnlyOneLot = orderline.product.isAllowOnlyOneLot();
+ const packLotLinesToEdit = orderline.getPackLotLinesToEdit(isAllowOnlyOneLot);
+ const { confirmed, payload } = await this.showPopup('EditListPopup', {
+ title: this.env._t('Lot/Serial Number(s) Required'),
+ isSingleItem: isAllowOnlyOneLot,
+ array: packLotLinesToEdit,
+ });
+ if (confirmed) {
+ // Segregate the old and new packlot lines
+ const modifiedPackLotLines = Object.fromEntries(
+ payload.newArray.filter(item => item.id).map(item => [item.id, item.text])
+ );
+ const newPackLotLines = payload.newArray
+ .filter(item => !item.id)
+ .map(item => ({ lot_name: item.text }));
+
+ orderline.setPackLotLines({ modifiedPackLotLines, newPackLotLines });
+ }
+ this.order.select_orderline(event.detail.orderline);
+ }
+ _onNewOrder(order) {
+ if (order) {
+ order.orderlines.on(
+ 'new-orderline-selected',
+ () => this.trigger('new-orderline-selected'),
+ this
+ );
+ order.orderlines.on('change', this._updateSummary, this);
+ order.orderlines.on(
+ 'add remove',
+ () => {
+ this.scrollToBottom = true;
+ this._updateSummary();
+ },
+ this
+ );
+ order.on('change', this.render, this);
+ }
+ this._updateSummary();
+ this.trigger('new-orderline-selected');
+ }
+ _onPrevOrder(order) {
+ if (order) {
+ order.orderlines.off('new-orderline-selected', null, this);
+ order.orderlines.off('change', null, this);
+ order.orderlines.off('add remove', null, this);
+ order.off('change', null, this);
+ }
+ }
+ _updateSummary() {
+ const total = this.order ? this.order.get_total_with_tax() : 0;
+ const tax = this.order ? total - this.order.get_total_without_tax() : 0;
+ this.state.total = this.env.pos.format_currency(total);
+ this.state.tax = this.env.pos.format_currency(tax);
+ this.render();
+ }
+ }
+ OrderWidget.template = 'OrderWidget';
+
+ Registries.Component.add(OrderWidget);
+
+ return OrderWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js
new file mode 100644
index 00000000..71a96bd4
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js
@@ -0,0 +1,25 @@
+odoo.define('point_of_sale.Orderline', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class Orderline extends PosComponent {
+ selectLine() {
+ this.trigger('select-line', { orderline: this.props.line });
+ }
+ lotIconClicked() {
+ this.trigger('edit-pack-lot-lines', { orderline: this.props.line });
+ }
+ get addedClasses() {
+ return {
+ selected: this.props.line.selected,
+ };
+ }
+ }
+ Orderline.template = 'Orderline';
+
+ Registries.Component.add(Orderline);
+
+ return Orderline;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
new file mode 100644
index 00000000..ac93500c
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js
@@ -0,0 +1,49 @@
+odoo.define('point_of_sale.ProductItem', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductItem extends PosComponent {
+ /**
+ * For accessibility, pressing <space> should be like clicking the product.
+ * <enter> is not considered because it conflicts with the barcode.
+ *
+ * @param {KeyPressEvent} event
+ */
+ spaceClickProduct(event) {
+ if (event.which === 32) {
+ this.trigger('click-product', this.props.product);
+ }
+ }
+ get imageUrl() {
+ const product = this.props.product;
+ return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
+ }
+ get pricelist() {
+ const current_order = this.env.pos.get_order();
+ if (current_order) {
+ return current_order.pricelist;
+ }
+ return this.env.pos.default_pricelist;
+ }
+ get price() {
+ const formattedUnitPrice = this.env.pos.format_currency(
+ this.props.product.get_price(this.pricelist, 1),
+ 'Product Price'
+ );
+ if (this.props.product.to_weight) {
+ return `${formattedUnitPrice}/${
+ this.env.pos.units_by_id[this.props.product.uom_id[0]].name
+ }`;
+ } else {
+ return formattedUnitPrice;
+ }
+ }
+ }
+ ProductItem.template = 'ProductItem';
+
+ Registries.Component.add(ProductItem);
+
+ return ProductItem;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js
new file mode 100644
index 00000000..aeee2ede
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js
@@ -0,0 +1,13 @@
+odoo.define('point_of_sale.ProductList', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductList extends PosComponent {}
+ ProductList.template = 'ProductList';
+
+ Registries.Component.add(ProductList);
+
+ return ProductList;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
new file mode 100644
index 00000000..65daa7cc
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
@@ -0,0 +1,327 @@
+odoo.define('point_of_sale.ProductScreen', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+ const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks');
+ const { useState } = owl.hooks;
+ const { parse } = require('web.field_utils');
+
+ class ProductScreen extends ControlButtonsMixin(PosComponent) {
+ constructor() {
+ super(...arguments);
+ useListener('update-selected-orderline', this._updateSelectedOrderline);
+ useListener('new-orderline-selected', this._newOrderlineSelected);
+ useListener('set-numpad-mode', this._setNumpadMode);
+ useListener('click-product', this._clickProduct);
+ useListener('click-customer', this._onClickCustomer);
+ useListener('click-pay', this._onClickPay);
+ useBarcodeReader({
+ product: this._barcodeProductAction,
+ weight: this._barcodeProductAction,
+ price: this._barcodeProductAction,
+ client: this._barcodeClientAction,
+ discount: this._barcodeDiscountAction,
+ error: this._barcodeErrorAction,
+ })
+ onChangeOrder(null, (newOrder) => newOrder && this.render());
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ triggerAtInput: 'update-selected-orderline',
+ useWithBarcode: true,
+ });
+ let status = this.showCashBoxOpening()
+ this.state = useState({ cashControl: status, numpadMode: 'quantity' });
+ this.mobile_pane = this.props.mobile_pane || 'right';
+ }
+ mounted() {
+ this.env.pos.on('change:selectedClient', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedClient', null, this);
+ }
+ /**
+ * To be overridden by modules that checks availability of
+ * connected scale.
+ * @see _onScaleNotAvailable
+ */
+ get isScaleAvailable() {
+ return true;
+ }
+ get client() {
+ return this.env.pos.get_client();
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ showCashBoxOpening() {
+ if(this.env.pos.config.cash_control && this.env.pos.pos_session.state == 'opening_control')
+ return true;
+ return false;
+ }
+ async _getAddProductOptions(product) {
+ let price_extra = 0.0;
+ let draftPackLotLines, weight, description, packLotLinesToEdit;
+
+ if (this.env.pos.config.product_configurator && _.some(product.attribute_line_ids, (id) => id in this.env.pos.attributes_by_ptal_id)) {
+ let attributes = _.map(product.attribute_line_ids, (id) => this.env.pos.attributes_by_ptal_id[id])
+ .filter((attr) => attr !== undefined);
+ let { confirmed, payload } = await this.showPopup('ProductConfiguratorPopup', {
+ product: product,
+ attributes: attributes,
+ });
+
+ if (confirmed) {
+ description = payload.selected_attributes.join(', ');
+ price_extra += payload.price_extra;
+ } else {
+ return;
+ }
+ }
+
+ // Gather lot information if required.
+ if (['serial', 'lot'].includes(product.tracking) && (this.env.pos.picking_type.use_create_lots || this.env.pos.picking_type.use_existing_lots)) {
+ const isAllowOnlyOneLot = product.isAllowOnlyOneLot();
+ if (isAllowOnlyOneLot) {
+ packLotLinesToEdit = [];
+ } else {
+ const orderline = this.currentOrder
+ .get_orderlines()
+ .filter(line => !line.get_discount())
+ .find(line => line.product.id === product.id);
+ if (orderline) {
+ packLotLinesToEdit = orderline.getPackLotLinesToEdit();
+ } else {
+ packLotLinesToEdit = [];
+ }
+ }
+ const { confirmed, payload } = await this.showPopup('EditListPopup', {
+ title: this.env._t('Lot/Serial Number(s) Required'),
+ isSingleItem: isAllowOnlyOneLot,
+ array: packLotLinesToEdit,
+ });
+ if (confirmed) {
+ // Segregate the old and new packlot lines
+ const modifiedPackLotLines = Object.fromEntries(
+ payload.newArray.filter(item => item.id).map(item => [item.id, item.text])
+ );
+ const newPackLotLines = payload.newArray
+ .filter(item => !item.id)
+ .map(item => ({ lot_name: item.text }));
+
+ draftPackLotLines = { modifiedPackLotLines, newPackLotLines };
+ } else {
+ // We don't proceed on adding product.
+ return;
+ }
+ }
+
+ // Take the weight if necessary.
+ if (product.to_weight && this.env.pos.config.iface_electronic_scale) {
+ // Show the ScaleScreen to weigh the product.
+ if (this.isScaleAvailable) {
+ const { confirmed, payload } = await this.showTempScreen('ScaleScreen', {
+ product,
+ });
+ if (confirmed) {
+ weight = payload.weight;
+ } else {
+ // do not add the product;
+ return;
+ }
+ } else {
+ await this._onScaleNotAvailable();
+ }
+ }
+
+ return { draftPackLotLines, quantity: weight, description, price_extra };
+ }
+ async _clickProduct(event) {
+ if (!this.currentOrder) {
+ this.env.pos.add_new_order();
+ }
+ const product = event.detail;
+ const options = await this._getAddProductOptions(product);
+ // Do not add product if options is undefined.
+ if (!options) return;
+ // Add the product after having the extra information.
+ this.currentOrder.add_product(product, options);
+ NumberBuffer.reset();
+ }
+ _setNumpadMode(event) {
+ const { mode } = event.detail;
+ NumberBuffer.capture();
+ NumberBuffer.reset();
+ this.state.numpadMode = mode;
+ }
+ async _updateSelectedOrderline(event) {
+ if(this.state.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) {
+ let order = this.env.pos.get_order();
+ let selectedLine = order.get_selected_orderline();
+ let lastId = order.orderlines.last().cid;
+ let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity();
+
+ if(selectedLine.noDecrease) {
+ this.showPopup('ErrorPopup', {
+ title: this.env._t('Invalid action'),
+ body: this.env._t('You are not allowed to change this quantity'),
+ });
+ return;
+ }
+ const parsedInput = event.detail.buffer && parse.float(event.detail.buffer) || 0;
+ if(lastId != selectedLine.cid)
+ this._showDecreaseQuantityPopup();
+ else if(currentQuantity < parsedInput)
+ this._setValue(event.detail.buffer);
+ else if(parsedInput < currentQuantity)
+ this._showDecreaseQuantityPopup();
+ } else {
+ let { buffer } = event.detail;
+ let val = buffer === null ? 'remove' : buffer;
+ this._setValue(val);
+ }
+ }
+ async _newOrderlineSelected() {
+ NumberBuffer.reset();
+ }
+ _setValue(val) {
+ if (this.currentOrder.get_selected_orderline()) {
+ if (this.state.numpadMode === 'quantity') {
+ this.currentOrder.get_selected_orderline().set_quantity(val);
+ } else if (this.state.numpadMode === 'discount') {
+ this.currentOrder.get_selected_orderline().set_discount(val);
+ } else if (this.state.numpadMode === 'price') {
+ var selected_orderline = this.currentOrder.get_selected_orderline();
+ selected_orderline.price_manually_set = true;
+ selected_orderline.set_unit_price(val);
+ }
+ if (this.env.pos.config.iface_customer_facing_display) {
+ this.env.pos.send_current_order_to_customer_facing_display();
+ }
+ }
+ }
+ async _barcodeProductAction(code) {
+ const product = this.env.pos.db.get_product_by_barcode(code.base_code)
+ if (!product) {
+ return this._barcodeErrorAction(code);
+ }
+ const options = await this._getAddProductOptions(product);
+ // Do not proceed on adding the product when no options is returned.
+ // This is consistent with _clickProduct.
+ if (!options) return;
+
+ // update the options depending on the type of the scanned code
+ if (code.type === 'price') {
+ Object.assign(options, { price: code.value });
+ } else if (code.type === 'weight') {
+ Object.assign(options, {
+ quantity: code.value,
+ merge: false,
+ });
+ } else if (code.type === 'discount') {
+ Object.assign(options, {
+ discount: code.value,
+ merge: false,
+ });
+ }
+ this.currentOrder.add_product(product, options)
+ }
+ _barcodeClientAction(code) {
+ const partner = this.env.pos.db.get_partner_by_barcode(code.code);
+ if (partner) {
+ if (this.currentOrder.get_client() !== partner) {
+ this.currentOrder.set_client(partner);
+ this.currentOrder.set_pricelist(
+ _.findWhere(this.env.pos.pricelists, {
+ id: partner.property_product_pricelist[0],
+ }) || this.env.pos.default_pricelist
+ );
+ }
+ return true;
+ }
+ this._barcodeErrorAction(code);
+ return false;
+ }
+ _barcodeDiscountAction(code) {
+ var last_orderline = this.currentOrder.get_last_orderline();
+ if (last_orderline) {
+ last_orderline.set_discount(code.value);
+ }
+ }
+ // IMPROVEMENT: The following two methods should be in PosScreenComponent?
+ // Why? Because once we start declaring barcode actions in different
+ // screens, these methods will also be declared over and over.
+ _barcodeErrorAction(code) {
+ this.showPopup('ErrorBarcodePopup', { code: this._codeRepr(code) });
+ }
+ _codeRepr(code) {
+ if (code.code.length > 32) {
+ return code.code.substring(0, 29) + '...';
+ } else {
+ return code.code;
+ }
+ }
+ /**
+ * override this method to perform procedure if the scale is not available.
+ * @see isScaleAvailable
+ */
+ async _onScaleNotAvailable() {}
+ async _showDecreaseQuantityPopup() {
+ const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', {
+ startingValue: 0,
+ title: this.env._t('Set the new quantity'),
+ });
+ let newQuantity = inputNumber !== "" ? parse.float(inputNumber) : null;
+ if (confirmed && newQuantity !== null) {
+ let order = this.env.pos.get_order();
+ let selectedLine = this.env.pos.get_order().get_selected_orderline();
+ let currentQuantity = selectedLine.get_quantity()
+ if(selectedLine.is_last_line() && currentQuantity === 1 && newQuantity < currentQuantity)
+ selectedLine.set_quantity(newQuantity);
+ else if(newQuantity >= currentQuantity)
+ selectedLine.set_quantity(newQuantity);
+ else {
+ let newLine = selectedLine.clone();
+ let decreasedQuantity = currentQuantity - newQuantity
+ newLine.order = order;
+
+ newLine.set_quantity( - decreasedQuantity, true);
+ order.add_orderline(newLine);
+ }
+ }
+ }
+ async _onClickCustomer() {
+ // IMPROVEMENT: This code snippet is very similar to selectClient of PaymentScreen.
+ const currentClient = this.currentOrder.get_client();
+ const { confirmed, payload: newClient } = await this.showTempScreen(
+ 'ClientListScreen',
+ { client: currentClient }
+ );
+ if (confirmed) {
+ this.currentOrder.set_client(newClient);
+ this.currentOrder.updatePricelist(newClient);
+ }
+ }
+ _onClickPay() {
+ this.showScreen('PaymentScreen');
+ }
+ switchPane() {
+ if (this.mobile_pane === "left") {
+ this.mobile_pane = "right";
+ this.render();
+ }
+ else {
+ this.mobile_pane = "left";
+ this.render();
+ }
+ }
+ }
+ ProductScreen.template = 'ProductScreen';
+
+ Registries.Component.add(ProductScreen);
+
+ return ProductScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js
new file mode 100644
index 00000000..17481058
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js
@@ -0,0 +1,88 @@
+odoo.define('point_of_sale.ProductsWidget', function(require) {
+ 'use strict';
+
+ const { useState } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useListener } = require('web.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductsWidget extends PosComponent {
+ /**
+ * @param {Object} props
+ * @param {number?} props.startCategoryId
+ */
+ constructor() {
+ super(...arguments);
+ useListener('switch-category', this._switchCategory);
+ useListener('update-search', this._updateSearch);
+ useListener('try-add-product', this._tryAddProduct);
+ useListener('clear-search', this._clearSearch);
+ this.state = useState({ searchWord: '' });
+ }
+ mounted() {
+ this.env.pos.on('change:selectedCategoryId', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedCategoryId', null, this);
+ }
+ get selectedCategoryId() {
+ return this.env.pos.get('selectedCategoryId');
+ }
+ get searchWord() {
+ return this.state.searchWord.trim();
+ }
+ get productsToDisplay() {
+ if (this.searchWord !== '') {
+ return this.env.pos.db.search_product_in_category(
+ this.selectedCategoryId,
+ this.searchWord
+ );
+ } else {
+ return this.env.pos.db.get_product_by_category(this.selectedCategoryId);
+ }
+ }
+ get subcategories() {
+ return this.env.pos.db
+ .get_category_childs_ids(this.selectedCategoryId)
+ .map(id => this.env.pos.db.get_category_by_id(id));
+ }
+ get breadcrumbs() {
+ if (this.selectedCategoryId === this.env.pos.db.root_category_id) return [];
+ return [
+ ...this.env.pos.db
+ .get_category_ancestors_ids(this.selectedCategoryId)
+ .slice(1),
+ this.selectedCategoryId,
+ ].map(id => this.env.pos.db.get_category_by_id(id));
+ }
+ get hasNoCategories() {
+ return this.env.pos.db.get_category_childs_ids(0).length === 0;
+ }
+ _switchCategory(event) {
+ this.env.pos.set('selectedCategoryId', event.detail);
+ }
+ _updateSearch(event) {
+ this.state.searchWord = event.detail;
+ }
+ _tryAddProduct(event) {
+ const searchResults = this.productsToDisplay;
+ // If the search result contains one item, add the product and clear the search.
+ if (searchResults.length === 1) {
+ const { searchWordInput } = event.detail;
+ this.trigger('click-product', searchResults[0]);
+ // the value of the input element is not linked to the searchWord state,
+ // so we clear both the state and the element's value.
+ searchWordInput.el.value = '';
+ this._clearSearch();
+ }
+ }
+ _clearSearch() {
+ this.state.searchWord = '';
+ }
+ }
+ ProductsWidget.template = 'ProductsWidget';
+
+ Registries.Component.add(ProductsWidget);
+
+ return ProductsWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js
new file mode 100644
index 00000000..fc2df5b0
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js
@@ -0,0 +1,33 @@
+odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) {
+ 'use strict';
+
+ const { useRef } = owl.hooks;
+ const { debounce } = owl.utils;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductsWidgetControlPanel extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.searchWordInput = useRef('search-word-input');
+ this.updateSearch = debounce(this.updateSearch, 100);
+ }
+ clearSearch() {
+ this.searchWordInput.el.value = '';
+ this.trigger('clear-search');
+ }
+ updateSearch(event) {
+ this.trigger('update-search', event.target.value);
+ if (event.key === 'Enter') {
+ // We are passing the searchWordInput ref so that when necessary,
+ // it can be modified by the parent.
+ this.trigger('try-add-product', { searchWordInput: this.searchWordInput });
+ }
+ }
+ }
+ ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel';
+
+ Registries.Component.add(ProductsWidgetControlPanel);
+
+ return ProductsWidgetControlPanel;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js
new file mode 100644
index 00000000..c06b6339
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js
@@ -0,0 +1,47 @@
+odoo.define('point_of_sale.OrderReceipt', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class OrderReceipt extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this._receiptEnv = this.props.order.getOrderReceiptEnv();
+ }
+ willUpdateProps(nextProps) {
+ this._receiptEnv = nextProps.order.getOrderReceiptEnv();
+ }
+ get receipt() {
+ return this.receiptEnv.receipt;
+ }
+ get orderlines() {
+ return this.receiptEnv.orderlines;
+ }
+ get paymentlines() {
+ return this.receiptEnv.paymentlines;
+ }
+ get isTaxIncluded() {
+ return Math.abs(this.receipt.subtotal - this.receipt.total_with_tax) <= 0.000001;
+ }
+ get receiptEnv () {
+ return this._receiptEnv;
+ }
+ isSimple(line) {
+ return (
+ line.discount === 0 &&
+ line.is_in_unit &&
+ line.quantity === 1 &&
+ !(
+ line.display_discount_policy == 'without_discount' &&
+ line.price < line.price_lst
+ )
+ );
+ }
+ }
+ OrderReceipt.template = 'OrderReceipt';
+
+ Registries.Component.add(OrderReceipt);
+
+ return OrderReceipt;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
new file mode 100644
index 00000000..720c65e4
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
@@ -0,0 +1,123 @@
+odoo.define('point_of_sale.ReceiptScreen', function (require) {
+ 'use strict';
+
+ const { Printer } = require('point_of_sale.Printer');
+ const { is_email } = require('web.utils');
+ const { useRef, useContext } = owl.hooks;
+ const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks');
+ const Registries = require('point_of_sale.Registries');
+ const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
+
+ const ReceiptScreen = (AbstractReceiptScreen) => {
+ class ReceiptScreen extends AbstractReceiptScreen {
+ constructor() {
+ super(...arguments);
+ useErrorHandlers();
+ onChangeOrder(null, (newOrder) => newOrder && this.render());
+ this.orderReceipt = useRef('order-receipt');
+ const order = this.currentOrder;
+ const client = order.get_client();
+ this.orderUiState = useContext(order.uiState.ReceiptScreen);
+ this.orderUiState.inputEmail = this.orderUiState.inputEmail || (client && client.email) || '';
+ this.is_email = is_email;
+ }
+ mounted() {
+ // Here, we send a task to the event loop that handles
+ // the printing of the receipt when the component is mounted.
+ // We are doing this because we want the receipt screen to be
+ // displayed regardless of what happen to the handleAutoPrint
+ // call.
+ setTimeout(async () => await this.handleAutoPrint(), 0);
+ }
+ async onSendEmail() {
+ if (!is_email(this.orderUiState.inputEmail)) {
+ this.orderUiState.emailSuccessful = false;
+ this.orderUiState.emailNotice = this.env._t('Invalid email.');
+ return;
+ }
+ try {
+ await this._sendReceiptToCustomer();
+ this.orderUiState.emailSuccessful = true;
+ this.orderUiState.emailNotice = this.env._t('Email sent.');
+ } catch (error) {
+ this.orderUiState.emailSuccessful = false;
+ this.orderUiState.emailNotice = this.env._t('Sending email failed. Please try again.');
+ }
+ }
+ get orderAmountPlusTip() {
+ const order = this.currentOrder;
+ const orderTotalAmount = order.get_total_with_tax();
+ const tip_product_id = this.env.pos.config.tip_product_id && this.env.pos.config.tip_product_id[0];
+ const tipLine = order
+ .get_orderlines()
+ .find((line) => tip_product_id && line.product.id === tip_product_id);
+ const tipAmount = tipLine ? tipLine.get_all_prices().priceWithTax : 0;
+ const orderAmountStr = this.env.pos.format_currency(orderTotalAmount - tipAmount);
+ if (!tipAmount) return orderAmountStr;
+ const tipAmountStr = this.env.pos.format_currency(tipAmount);
+ return `${orderAmountStr} + ${tipAmountStr} tip`;
+ }
+ get currentOrder() {
+ return this.env.pos.get_order();
+ }
+ get nextScreen() {
+ return { name: 'ProductScreen' };
+ }
+ whenClosing() {
+ this.orderDone();
+ }
+ /**
+ * This function is called outside the rendering call stack. This way,
+ * we don't block the displaying of ReceiptScreen when it is mounted; additionally,
+ * any error that can happen during the printing does not affect the rendering.
+ */
+ async handleAutoPrint() {
+ if (this._shouldAutoPrint()) {
+ await this.printReceipt();
+ if (this.currentOrder._printed && this._shouldCloseImmediately()) {
+ this.whenClosing();
+ }
+ }
+ }
+ orderDone() {
+ this.currentOrder.finalize();
+ const { name, props } = this.nextScreen;
+ this.showScreen(name, props);
+ }
+ async printReceipt() {
+ const isPrinted = await this._printReceipt();
+ if (isPrinted) {
+ this.currentOrder._printed = true;
+ }
+ }
+ _shouldAutoPrint() {
+ return this.env.pos.config.iface_print_auto && !this.currentOrder._printed;
+ }
+ _shouldCloseImmediately() {
+ var invoiced_finalized = this.currentOrder.is_to_invoice() ? this.currentOrder.finalized : true;
+ return this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized;
+ }
+ async _sendReceiptToCustomer() {
+ const printer = new Printer();
+ const receiptString = this.orderReceipt.comp.el.outerHTML;
+ const ticketImage = await printer.htmlToImg(receiptString);
+ const order = this.currentOrder;
+ const client = order.get_client();
+ const orderName = order.get_name();
+ const orderClient = { email: this.orderUiState.inputEmail, name: client ? client.name : this.orderUiState.inputEmail };
+ const order_server_id = this.env.pos.validated_orders_name_server_id_map[orderName];
+ await this.rpc({
+ model: 'pos.order',
+ method: 'action_receipt_to_customer',
+ args: [[order_server_id], orderName, orderClient, ticketImage],
+ });
+ }
+ }
+ ReceiptScreen.template = 'ReceiptScreen';
+ return ReceiptScreen;
+ };
+
+ Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen);
+
+ return ReceiptScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js
new file mode 100644
index 00000000..e7527eee
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js
@@ -0,0 +1,18 @@
+odoo.define('point_of_sale.WrappedProductNameLines', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class WrappedProductNameLines extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.line = this.props.line;
+ }
+ }
+ WrappedProductNameLines.template = 'WrappedProductNameLines';
+
+ Registries.Component.add(WrappedProductNameLines);
+
+ return WrappedProductNameLines;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js
new file mode 100644
index 00000000..f9b1ea97
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js
@@ -0,0 +1,102 @@
+odoo.define('point_of_sale.ScaleScreen', function(require) {
+ 'use strict';
+
+ const { useState, useExternalListener } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { round_precision: round_pr } = require('web.utils');
+ const Registries = require('point_of_sale.Registries');
+
+ class ScaleScreen extends PosComponent {
+ /**
+ * @param {Object} props
+ * @param {Object} props.product The product to weight.
+ */
+ constructor() {
+ super(...arguments);
+ useExternalListener(document, 'keyup', this._onHotkeys);
+ this.state = useState({ weight: 0 });
+ }
+ mounted() {
+ // start the scale reading
+ this._readScale();
+ }
+ willUnmount() {
+ // stop the scale reading
+ this.env.pos.proxy_queue.clear();
+ }
+ back() {
+ this.props.resolve({ confirmed: false, payload: null });
+ this.trigger('close-temp-screen');
+ }
+ confirm() {
+ this.props.resolve({
+ confirmed: true,
+ payload: { weight: this.state.weight },
+ });
+ this.trigger('close-temp-screen');
+ }
+ _onHotkeys(event) {
+ if (event.key === 'Escape') {
+ this.back();
+ } else if (event.key === 'Enter') {
+ this.confirm();
+ }
+ }
+ _readScale() {
+ this.env.pos.proxy_queue.schedule(this._setWeight.bind(this), {
+ duration: 500,
+ repeat: true,
+ });
+ }
+ async _setWeight() {
+ const reading = await this.env.pos.proxy.scale_read();
+ this.state.weight = reading.weight;
+ }
+ get _activePricelist() {
+ const current_order = this.env.pos.get_order();
+ let current_pricelist = this.env.pos.default_pricelist;
+ if (current_order) {
+ current_pricelist = current_order.pricelist;
+ }
+ return current_pricelist;
+ }
+ get productWeightString() {
+ const defaultstr = (this.state.weight || 0).toFixed(3) + ' Kg';
+ if (!this.props.product || !this.env.pos) {
+ return defaultstr;
+ }
+ const unit_id = this.props.product.uom_id;
+ if (!unit_id) {
+ return defaultstr;
+ }
+ const unit = this.env.pos.units_by_id[unit_id[0]];
+ const weight = round_pr(this.state.weight || 0, unit.rounding);
+ let weightstr = weight.toFixed(Math.ceil(Math.log(1.0 / unit.rounding) / Math.log(10)));
+ weightstr += ' ' + unit.name;
+ return weightstr;
+ }
+ get computedPriceString() {
+ return this.env.pos.format_currency(this.productPrice * this.state.weight);
+ }
+ get productPrice() {
+ const product = this.props.product;
+ return (product ? product.get_price(this._activePricelist, this.state.weight) : 0) || 0;
+ }
+ get productName() {
+ return (
+ (this.props.product ? this.props.product.display_name : undefined) ||
+ 'Unnamed Product'
+ );
+ }
+ get productUom() {
+ return this.props.product
+ ? this.env.pos.units_by_id[this.props.product.uom_id[0]].name
+ : '';
+ }
+ }
+ ScaleScreen.template = 'ScaleScreen';
+
+ Registries.Component.add(ScaleScreen);
+
+ return ScaleScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
new file mode 100644
index 00000000..f59b72d0
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
@@ -0,0 +1,220 @@
+odoo.define('point_of_sale.TicketScreen', function (require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+ const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
+ const { useListener } = require('web.custom_hooks');
+ const { posbus } = require('point_of_sale.utils');
+
+ class TicketScreen extends IndependentToOrderScreen {
+ constructor() {
+ super(...arguments);
+ useListener('close-screen', this.close);
+ useListener('filter-selected', this._onFilterSelected);
+ useListener('search', this._onSearch);
+ this.searchDetails = {};
+ this.filter = null;
+ this._initializeSearchFieldConstants();
+ }
+ mounted() {
+ posbus.on('ticket-button-clicked', this, this.close);
+ this.env.pos.get('orders').on('add remove change', () => this.render(), this);
+ this.env.pos.on('change:selectedOrder', () => this.render(), this);
+ }
+ willUnmount() {
+ posbus.off('ticket-button-clicked', this);
+ this.env.pos.get('orders').off('add remove change', null, this);
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ _onFilterSelected(event) {
+ this.filter = event.detail.filter;
+ this.render();
+ }
+ _onSearch(event) {
+ const searchDetails = event.detail;
+ Object.assign(this.searchDetails, searchDetails);
+ this.render();
+ }
+ /**
+ * Override to conditionally show the new ticket button.
+ */
+ get showNewTicketButton() {
+ return true;
+ }
+ get orderList() {
+ return this.env.pos.get_order_list();
+ }
+ get filteredOrderList() {
+ const { AllTickets } = this.getOrderStates();
+ const filterCheck = (order) => {
+ if (this.filter && this.filter !== AllTickets) {
+ const screen = order.get_screen_data();
+ return this.filter === this.constants.screenToStatusMap[screen.name];
+ }
+ return true;
+ };
+ const { fieldValue, searchTerm } = this.searchDetails;
+ const fieldAccessor = this._searchFields[fieldValue];
+ const searchCheck = (order) => {
+ if (!fieldAccessor) return true;
+ const fieldValue = fieldAccessor(order);
+ if (fieldValue === null) return true;
+ if (!searchTerm) return true;
+ return fieldValue && fieldValue.toString().toLowerCase().includes(searchTerm.toLowerCase());
+ };
+ const predicate = (order) => {
+ return filterCheck(order) && searchCheck(order);
+ };
+ return this.orderList.filter(predicate);
+ }
+ selectOrder(order) {
+ this._setOrder(order);
+ if (order === this.env.pos.get_order()) {
+ this.close();
+ }
+ }
+ _setOrder(order) {
+ this.env.pos.set_order(order);
+ }
+ createNewOrder() {
+ this.env.pos.add_new_order();
+ }
+ async deleteOrder(order) {
+ const screen = order.get_screen_data();
+ if (['ProductScreen', 'PaymentScreen'].includes(screen.name) && order.get_orderlines().length > 0) {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: 'Existing orderlines',
+ body: `${order.name} has total amount of ${this.getTotal(
+ order
+ )}, are you sure you want delete this order?`,
+ });
+ if (!confirmed) return;
+ }
+ if (order) {
+ await this._canDeleteOrder(order);
+ order.destroy({ reason: 'abandon' });
+ }
+ posbus.trigger('order-deleted');
+ }
+ getDate(order) {
+ return moment(order.creation_date).format('YYYY-MM-DD hh:mm A');
+ }
+ getTotal(order) {
+ return this.env.pos.format_currency(order.get_total_with_tax());
+ }
+ getCustomer(order) {
+ return order.get_client_name();
+ }
+ getCardholderName(order) {
+ return order.get_cardholder_name();
+ }
+ getEmployee(order) {
+ return order.employee ? order.employee.name : '';
+ }
+ getStatus(order) {
+ const screen = order.get_screen_data();
+ return this.constants.screenToStatusMap[screen.name];
+ }
+ /**
+ * Hide the delete button if one of the payments is a 'done' electronic payment.
+ */
+ hideDeleteButton(order) {
+ return order
+ .get_paymentlines()
+ .some((payment) => payment.is_electronic() && payment.get_payment_status() === 'done');
+ }
+ showCardholderName() {
+ return this.env.pos.payment_methods.some(method => method.use_payment_terminal);
+ }
+ get searchBarConfig() {
+ return {
+ searchFields: this.constants.searchFieldNames,
+ filter: { show: true, options: this.filterOptions },
+ };
+ }
+ get filterOptions() {
+ const { AllTickets, Ongoing, Payment, Receipt } = this.getOrderStates();
+ return [AllTickets, Ongoing, Payment, Receipt];
+ }
+ /**
+ * An object with keys containing the search field names which map to functions.
+ * The mapped functions will be used to generate representative string for the order
+ * to match the search term when searching.
+ * E.g. Given 2 orders, search those with `Receipt Number` containing `1111`.
+ * ```
+ * orders = [{
+ * name: '000-1111-222'
+ * total: 10,
+ * }, {
+ * name: '444-5555-666'
+ * total: 15,
+ * }]
+ * ```
+ * `Receipt Number` search field maps to the `name` of the order. So, the orders will be
+ * represented by their name, and the search will result to:
+ * ```
+ * result = [{
+ * name: '000-1111-222',
+ * total: 10,
+ * }]
+ * ```
+ * @returns Record<string, (models.Order) => string>
+ */
+ get _searchFields() {
+ const { ReceiptNumber, Date, Customer, CardholderName } = this.getSearchFieldNames();
+ var fields = {
+ [ReceiptNumber]: (order) => order.name,
+ [Date]: (order) => moment(order.creation_date).format('YYYY-MM-DD hh:mm A'),
+ [Customer]: (order) => order.get_client_name(),
+ };
+
+ if (this.showCardholderName()) {
+ fields[CardholderName] = (order) => order.get_cardholder_name();
+ }
+
+ return fields;
+ }
+ /**
+ * Maps the order screen params to order status.
+ */
+ get _screenToStatusMap() {
+ const { Ongoing, Payment, Receipt } = this.getOrderStates();
+ return {
+ ProductScreen: Ongoing,
+ PaymentScreen: Payment,
+ ReceiptScreen: Receipt,
+ };
+ }
+ _initializeSearchFieldConstants() {
+ this.constants = {};
+ Object.assign(this.constants, {
+ searchFieldNames: Object.keys(this._searchFields),
+ screenToStatusMap: this._screenToStatusMap,
+ });
+ }
+ async _canDeleteOrder(order) {
+ return true;
+ }
+ getOrderStates() {
+ return {
+ AllTickets: this.env._t('All Tickets'),
+ Ongoing: this.env._t('Ongoing'),
+ Payment: this.env._t('Payment'),
+ Receipt: this.env._t('Receipt'),
+ };
+ }
+ getSearchFieldNames() {
+ return {
+ ReceiptNumber: this.env._t('Receipt Number'),
+ Date: this.env._t('Date'),
+ Customer: this.env._t('Customer'),
+ CardholderName: this.env._t('Cardholder Name'),
+ };
+ }
+ }
+ TicketScreen.template = 'TicketScreen';
+
+ Registries.Component.add(TicketScreen);
+
+ return TicketScreen;
+});