diff options
Diffstat (limited to 'addons/point_of_sale/static/src/js')
12 files changed, 391 insertions, 85 deletions
diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js index 7f950cfab71..e01a434d9a8 100644 --- a/addons/point_of_sale/static/src/js/Chrome.js +++ b/addons/point_of_sale/static/src/js/Chrome.js @@ -197,7 +197,7 @@ odoo.define('point_of_sale.Chrome', function(require) { this.showScreen(name, props); } _getSavedScreen(order) { - return order.get_screen_data('screen') || { name: 'ProductScreen' }; + return order.get_screen_data(); } __showTempScreen(event) { const { name, props, resolve } = event.detail; @@ -236,7 +236,7 @@ odoo.define('point_of_sale.Chrome', function(require) { _setScreenData(name, props) { const order = this.env.pos.get_order(); if (order) { - order.set_screen_data('screen', { name, props }); + order.set_screen_data({ name, props }); } } async _closePos() { @@ -325,6 +325,10 @@ odoo.define('point_of_sale.Chrome', function(require) { } } + get isTicketScreenShown() { + return this.mainScreen.name === 'TicketScreen'; + } + // MISC METHODS // async _loadDemoData() { diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js deleted file mode 100644 index ad2d8265417..00000000000 --- a/addons/point_of_sale/static/src/js/ChromeWidgets/OrderSelector.js +++ /dev/null @@ -1,56 +0,0 @@ -odoo.define('point_of_sale.OrderSelector', function(require) { - 'use strict'; - - const { useContext } = owl.hooks; - const PosComponent = require('point_of_sale.PosComponent'); - const Registries = require('point_of_sale.Registries'); - const contexts = require('point_of_sale.PosContext'); - - // Previously OrderSelectorWidget - class OrderSelector extends PosComponent { - constructor() { - super(...arguments); - // this is used in its template - this.chromeContext = useContext(contexts.chrome); - } - mounted() { - this.env.pos.get('orders').on('add remove change', () => this.render(), this); - this.env.pos.on('change:selectedOrder', () => this.render(), this); - } - willUnmount() { - this.env.pos.get('orders').off('add remove change', null, this); - this.env.pos.off('change:selectedOrder', null, this); - } - selectOrder(order) { - this.env.pos.set_order(order); - } - addNewOrder() { - this.env.pos.add_new_order(); - } - get orderList() { - return this.env.pos.get_order_list(); - } - async deleteCurrentOrder() { - const order = this.env.pos.get_order(); - if (!order) { - return; - } else if (!order.is_empty()) { - const { confirmed } = await this.showPopup('ConfirmPopup', { - title: this.env._t('Destroy Current Order ?'), - body: this.env._t('You will lose any data associated with the current order'), - }); - if (confirmed) this.env.pos.delete_current_order(); - } else { - this.env.pos.delete_current_order(); - } - } - creationTime(order) { - return moment(order.creation_date).format('hh:mm'); - } - } - OrderSelector.template = 'OrderSelector'; - - Registries.Component.add(OrderSelector); - - return OrderSelector; -}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js new file mode 100644 index 00000000000..d142bbdeac0 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js @@ -0,0 +1,41 @@ +odoo.define('point_of_sale.TicketButton', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { posbus } = require('point_of_sale.utils'); + + class TicketButton extends PosComponent { + onClick() { + if (this.props.isTicketScreenShown) { + posbus.trigger('ticket-button-clicked'); + } else { + this.showScreen('TicketScreen'); + } + } + willPatch() { + posbus.off('order-deleted', this); + } + patched() { + posbus.on('order-deleted', this, this.render); + } + mounted() { + posbus.on('order-deleted', this, this.render); + } + willUnmount() { + posbus.off('order-deleted', this); + } + get count() { + if (this.env.pos) { + return this.env.pos.get_order_list().length; + } else { + return 0; + } + } + } + TicketButton.template = 'TicketButton'; + + Registries.Component.add(TicketButton); + + return TicketButton; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js index 939f0daa43f..e2f2148b0c7 100644 --- a/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js +++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js @@ -4,13 +4,18 @@ odoo.define('point_of_sale.IndependentToOrderScreen', function (require) { const PosComponent = require('point_of_sale.PosComponent'); class IndependentToOrderScreen extends PosComponent { + /** + * Alias the forceTriggerSelectedOrder method as it also + * means 'closing' this screen. + */ close() { - // To close this order-indenpendent screen, we forcefully trigger change + this.forceTriggerSelectedOrder(); + } + forceTriggerSelectedOrder() { + // Calling this method forcefully trigger change // on the selectedOrder attribute, which then shows the screen of the - // current order. - this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order(), { - silent: true, - }); + // current order, essentially closing this screen. + this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order()); } } diff --git a/addons/point_of_sale/static/src/js/Misc/SearchBar.js b/addons/point_of_sale/static/src/js/Misc/SearchBar.js new file mode 100644 index 00000000000..d9bcfe879b1 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/SearchBar.js @@ -0,0 +1,115 @@ +odoo.define('point_of_sale.SearchBar', function (require) { + 'use strict'; + + const { useState, useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * This is a simple configurable search bar component. It has search fields + * and selection filter. Search fields allow the users to specify the type + * of their searches. The filter is a dropdown menu for selection. Depending on + * user's action, this component emits corresponding event with the action + * information (payload). + * + * TODO: This component can be made more generic and be able to replace + * all the search bars across pos ui. + * + * @prop {{ + * config: { + * searchFields: string[], + * filter: { show: boolean, options: string[] } + * }, + * placeholder: string, + * }} + * @emits search @payload { fieldValue: string, searchTerm: '' } + * @emits filter-selected @payload { filter: string } + * + * NOTE: The payload of the emitted event is accessible via the `detail` + * field of the event. + */ + class SearchBar extends PosComponent { + constructor() { + super(...arguments); + this.config = this.props.config; + this.state = useState({ + searchInput: '', + selectedFieldId: this.config.searchFields.length ? 0 : null, + showSearchFields: false, + showFilterOptions: false, + selectedFilter: this.config.filter.options[0] || 'Select', + }); + useExternalListener(window, 'click', this._hideOptions); + } + selectFilter(option) { + this.state.selectedFilter = option; + this.trigger('filter-selected', { filter: this.state.selectedFilter }); + } + get placeholder() { + return this.props.placeholder; + } + /** + * When vertical arrow keys are pressed, select fields for searching. + * When enter key is pressed, trigger search event if there is searchInput. + */ + onKeydown(event) { + if (['ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + this.state.selectedFieldId = this._fieldIdToSelect(event.key); + } else if (event.key === 'Enter') { + this.trigger('search', { + fieldValue: this.config.searchFields[this.state.selectedFieldId], + searchTerm: this.state.searchInput, + }); + this.state.showSearchFields = false; + } else { + if (this.state.selectedFieldId === null && this.config.searchFields.length) { + this.state.selectedFieldId = 0; + } + this.state.showSearchFields = true; + } + } + /** + * Called when a search field is clicked. + */ + onClickSearchField(id) { + this.state.showSearchFields = false; + this.trigger('search', { + fieldValue: this.config.searchFields[id], + searchTerm: this.state.searchInput, + }); + } + /** + * Given an arrow key, return the next selectedFieldId. + * E.g. If the selectedFieldId is 1 and ArrowDown is pressed, return 2. + * + * @param {string} key vertical arrow key + */ + _fieldIdToSelect(key) { + const length = this.config.searchFields.length; + if (!length) return null; + if (this.state.selectedFieldId === null) return 0; + const current = this.state.selectedFieldId || length; + return (current + (key === 'ArrowDown' ? 1 : -1)) % length; + } + _hideOptions() { + this.state.showFilterOptions = false; + this.state.showSearchFields = false; + } + } + SearchBar.template = 'point_of_sale.SearchBar'; + SearchBar.defaultProps = { + config: { + searchFields: [], + filter: { + show: false, + options: [], + }, + }, + placeholder: 'Search ...', + }; + + Registries.Component.add(SearchBar); + + return SearchBar; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js index f4b80c12190..dcde9739f60 100644 --- a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js @@ -86,10 +86,9 @@ odoo.define('point_of_sale.OrderManagementScreen', function (require) { * @param {models.Order} order */ _setOrder(order) { + this.env.pos.set_order(order); if (order === this.env.pos.get_order()) { this.close(); - } else { - this.env.pos.set_order(order); } } } 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 index 641fb621f4b..2d552e4a1d4 100644 --- a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js @@ -23,8 +23,6 @@ odoo.define('point_of_sale.PaymentScreen', function (require) { useListener('send-payment-cancel', this._sendPaymentCancel); useListener('send-payment-reverse', this._sendPaymentReverse); useListener('send-force-done', this._sendForceDone); - onChangeOrder(this._onPrevOrder, this._onNewOrder); - useErrorHandlers(); NumberBuffer.use({ // The numberBuffer listens to this event to update its state. // Basically means 'update the buffer when this event is triggered' @@ -33,6 +31,8 @@ odoo.define('point_of_sale.PaymentScreen', function (require) { // Note that the component listens to it. triggerAtInput: 'update-selected-paymentline', }); + onChangeOrder(this._onPrevOrder, this._onNewOrder); + useErrorHandlers(); this.payment_interface = null; } get currentOrder() { 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 index 5bb49de5f07..8475697926d 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js @@ -54,6 +54,9 @@ odoo.define('point_of_sale.ProductsWidget', function(require) { 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); } 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 00000000000..724766e93da --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js @@ -0,0 +1,181 @@ +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 filterCheck = (order) => { + if (this.filter && this.filter !== 'All Tickets') { + 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) { + 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(); + } + getEmployee(order) { + return 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'); + } + get searchBarConfig() { + return { + searchFields: this.constants.searchFieldNames, + filter: { show: true, options: ['All Tickets', '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() { + return { + 'Receipt Number': (order) => order.name, + Date: (order) => moment(order.creation_date).format('YYYY-MM-DD hh:mm A'), + Customer: (order) => order.get_client_name(), + }; + } + /** + * Maps the order screen params to order status. + */ + get _screenToStatusMap() { + return { + ProductScreen: 'Ongoing', + PaymentScreen: 'Payment', + ReceiptScreen: 'Receipt', + }; + } + _initializeSearchFieldConstants() { + this.constants = {}; + Object.assign(this.constants, { + searchFieldNames: Object.keys(this._searchFields), + screenToStatusMap: this._screenToStatusMap, + }); + } + } + TicketScreen.template = 'TicketScreen'; + + Registries.Component.add(TicketScreen); + + return TicketScreen; +}); diff --git a/addons/point_of_sale/static/src/js/main.js b/addons/point_of_sale/static/src/js/main.js index dc8ea5d7b7b..346a61679f4 100644 --- a/addons/point_of_sale/static/src/js/main.js +++ b/addons/point_of_sale/static/src/js/main.js @@ -11,6 +11,8 @@ odoo.define('web.web_client', function (require) { owl.config.mode = env.isDebug() ? 'dev' : 'prod'; owl.Component.env = env; + Registries.Component.add(owl.misc.Portal); + function setupResponsivePlugin(env) { const isMobile = () => window.innerWidth <= 768; env.isMobile = isMobile(); diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 44e9527ba9c..6d0d5ff63f8 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -717,11 +717,11 @@ exports.PosModel = Backbone.Model.extend({ var order_list = this.get_order_list(); if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){ // when we intentionally remove an unfinished order, and there is another existing one - this.set_order(order_list[index] || order_list[order_list.length -1]); + this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true }); }else{ // when the order was automatically removed after completion, // or when we intentionally delete the only concurrent order - this.add_new_order(); + this.add_new_order({ silent: true }); } }, @@ -739,10 +739,10 @@ exports.PosModel = Backbone.Model.extend({ this.db.set_cashier(this.get('cashier')); }, // creates a new empty order and sets it as the current order - add_new_order: function(){ + add_new_order: function(options){ var order = new exports.Order({},{pos:this}); this.get('orders').add(order); - this.set('selectedOrder', order); + this.set('selectedOrder', order, options); return order; }, /** @@ -810,8 +810,8 @@ exports.PosModel = Backbone.Model.extend({ }, // change the current order - set_order: function(order){ - this.set({ selectedOrder: order }); + set_order: function(order, options){ + this.set({ selectedOrder: order }, options); }, // return the list of unpaid orders @@ -2469,6 +2469,12 @@ exports.Paymentline = Backbone.Model.extend({ ticket: this.ticket, }; }, + // If payment status is a non-empty string, then it is an electronic payment. + // TODO: There has to be a less confusing way to distinguish simple payments + // from electronic transactions. Perhaps use a flag? + is_electronic: function() { + return Boolean(this.get_payment_status()); + }, }); var PaymentlineCollection = Backbone.Collection.extend({ @@ -3280,23 +3286,27 @@ exports.Order = Backbone.Model.extend({ // the order also stores the screen status, as the PoS supports // different active screens per order. This method is used to // store the screen status. - set_screen_data: function(key,value){ - if(arguments.length === 2){ - this.screen_data[key] = value; - }else if(arguments.length === 1){ - for(var key in arguments[0]){ - this.screen_data[key] = arguments[0][key]; - } - } + set_screen_data: function(value){ + this.screen_data['value'] = value; }, //see set_screen_data - get_screen_data: function(key){ - return this.screen_data[key]; + get_screen_data: function(){ + const screen = this.screen_data['value']; + // If no screen data is saved + // no payment line -> product screen + // with payment line -> payment screen + if (!screen) { + if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' }; + return { name: 'ProductScreen' }; + } + if (screen.name !== 'ReceiptScreen' && this.get_paymentlines().length > 0) { + return { name: 'PaymentScreen' }; + } + return screen; }, wait_for_push_order: function () { return this.is_to_email(); }, - /** * @returns {Object} object to use as props for instantiating OrderReceipt. */ diff --git a/addons/point_of_sale/static/src/js/utils.js b/addons/point_of_sale/static/src/js/utils.js index 26442ef776b..7aa7b35e93d 100644 --- a/addons/point_of_sale/static/src/js/utils.js +++ b/addons/point_of_sale/static/src/js/utils.js @@ -1,6 +1,8 @@ odoo.define('point_of_sale.utils', function (require) { 'use strict'; + const { EventBus } = owl.core; + function getFileAsText(file) { return new Promise((resolve, reject) => { if (!file) { @@ -43,5 +45,5 @@ odoo.define('point_of_sale.utils', function (require) { ); } - return { getFileAsText, nextFrame, isRpcError }; + return { getFileAsText, nextFrame, isRpcError, posbus: new EventBus() }; }); |
