diff options
| author | Joseph Caburnay <jcb@odoo.com> | 2020-07-09 12:07:14 +0000 |
|---|---|---|
| committer | Joseph Caburnay <jcb@odoo.com> | 2020-08-05 13:35:08 +0000 |
| commit | 9ea0aca6ea30fe42e1a7e0a4eaeb79f92a521152 (patch) | |
| tree | b3c969ef00f0bbc637d94c124202a96887df450a /addons/point_of_sale/static | |
| parent | 71f719a61ea34252abdc9f1b2fb2caf8e62c7917 (diff) | |
[IMP] point_of_sale,pos_*: top bar improvements and ticket list
The following changes are made in this task:
1. Remove order tabs and replace with ticket list.
2. Top bar color change.
3. Search bar in the product screen is moved in the top bar.
4. List of sub-categories are now in the same line as the category
breadcrumbs.
closes odoo/odoo#55100
Task-id: 2276678
Signed-off-by: pimodoo <pimodoo@users.noreply.github.com>
Diffstat (limited to 'addons/point_of_sale/static')
29 files changed, 1128 insertions, 288 deletions
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index 39ee679e8ca..62321e1932c 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -319,22 +319,24 @@ td { margin:0; padding:0; color: gray; - background: #393939; + background: #875A7B; display: flex; } /* a) The left part of the top-bar */ .pos .pos-branding{ - max-width: 459px; + min-width: 503px; + max-width: 503px; flex-grow: 1; height:100%; margin:0; padding:0; - border-right: 1px solid #373737; text-align:left; line-height:100%; vertical-align: middle; + display: flex; + justify-content: space-between; } .pos .pos-logo { height: 35px; @@ -352,6 +354,10 @@ td { cursor: pointer; } +.pos .ticket-button { + display: flex; +} + /* b) The right part of the top-bar */ .pos .pos-rightheader { @@ -362,7 +368,17 @@ td { overflow-x: auto; } .pos .pos-rightheader > * { - border-right: 1px solid #292929; + border-right: 1px solid #875A7B; +} + +.pos .status-buttons-portal { + flex: 1; +} + +.pos .status-buttons { + display: flex; + justify-content: flex-end; + flex: 1; } .pos .order-button{ @@ -456,8 +472,8 @@ td { height: 48px; padding-left: 16px; padding-right: 16px; - border-right: 1px solid #292929; - border-left: 1px solid #292929; + border-right: 1px solid #875A7B; + border-left: 1px solid #875A7B; color: #DDD; line-height: 48px; text-align: center; @@ -468,7 +484,7 @@ td { -webkit-transition-timing-function: ease-out; } .pos .pos-rightheader .header-button:last-child{ - border-left: 1px solid #3a3a3a; + border-left: 1px solid #875A7B; } .pos .pos-rightheader .header-button:active{ background: rgba(0,0,0,0.2); @@ -852,11 +868,18 @@ td { .pos .rightpane-header { padding: 0; - height: 48px; - border-bottom: 1px solid #c7c7c7; background: #d3d3d3; text-align: center; display: flex; + flex-flow: row wrap; +} + +.pos .green-border-bottom { + border-bottom: solid 3px rgb(110, 200, 155); +} + +.pos .grey-border-bottom { + border-bottom: 1px solid #c7c7c7; } /* ********* The product list ********* */ @@ -885,11 +908,6 @@ td { /* a) the product list navigation bar */ -.pos .breadcrumbs{ - display: inline-block; - text-align: left; - float:left; -} .pos .breadcrumb{ float: left; display: inline-block; @@ -911,8 +929,6 @@ td { } .pos .breadcrumb-button.breadcrumb-home { line-height: 50px; - padding: 0; - width: 50px; font-size: 25px; text-align: center; } @@ -936,16 +952,13 @@ td { /* b) the search box */ .pos .searchbox { - position: absolute; - right: 2px; flex-grow: 1; + position: relative; } .pos .searchbox input { - width: 100px; + width: 150px; border: 1px solid #cecbcb; padding: 10px 20px; - padding-left: 38px; - padding-right: 33px; margin: 6px; background-color: white; border-radius: 20px; @@ -959,7 +972,6 @@ td { } .pos .search-clear { - position: absolute; top: 9px; width: 30px; height: 30px; @@ -971,12 +983,13 @@ td { .search-clear.left { left: 11px; color: #808080; + margin: 6px; } .search-clear.right { - left: 145px; color: #808080; cursor: pointer; + margin: 6px; } @media screen and (max-width: 768px) { @@ -1004,6 +1017,7 @@ td { .pos .categories { position: relative; border-bottom: solid 3px rgb(110, 200, 155); + flex: 1; } .pos .categories h4 { display: inline-block; @@ -1016,7 +1030,7 @@ td { box-sizing: border-box; width:100%; height:100%; - max-height:60vh; + max-height:40vh; overflow: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; @@ -1027,16 +1041,13 @@ td { text-align: left; padding: 10px; background: rgb(229, 229, 229); - border-bottom: 1px solid #cecece; } .pos .category-list.simple { padding: 0px; background: #cecece; - display: -webkit-flex; display: flex; - -webkit-flex-flow: row wrap; flex-flow: row wrap; - border-bottom: none; + flex: 1; } @@ -1058,17 +1069,16 @@ td { } .pos .category-simple-button{ - position: relative; - display: inline-block; + display: flex; + align-items: center; font-size: 14px; - margin-right: 1px; - margin-bottom: 1px; padding: 5px 12px; - line-height: 32px; - flex-grow: 1; - -webkit-flex-grow: 1; cursor: pointer; - background: #e2e2e2; + flex: 1; + text-align: left; + background: rgb(229, 229, 229); + border-right: solid 1px #d3d3d3; + border-top: solid 1px #d3d3d3; } .pos .category-simple-button:active{ color: white; @@ -1372,12 +1382,6 @@ td { flex-grow: 1; } -.pos .screen .header-row { - border:none; - width:100%; - height:0px; -} - .pos .screen .header-cell{ border:none; width:100%; @@ -3144,3 +3148,332 @@ td { .pos .product_configurator_attributes .configurator_color.active input { margin: 18px; } + +/* TICKET SCREEN */ + +.ticket-screen { + font-size: medium; +} + +.ticket-screen .orders { + display: flex; + flex-flow: column nowrap; + overflow: hidden; + overflow-y: hidden; + overflow-y: auto; +} + +.ticket-screen .orders .header-row{ + display: flex; + flex-flow: row nowrap; + flex: 1; + justify-content: space-evenly; + background: #868686; + color: white; +} + +.ticket-screen .orders .order-row { + display: flex; + flex-flow: row nowrap; + flex: 1; + justify-content: space-evenly; +} + +.ticket-screen .orders .col { + display: flex; + flex: 1; + padding: 10px; +} + +.ticket-screen .orders .col.start { + justify-content: flex-start; +} + +.ticket-screen .orders .col.center { + justify-content: center; +} + +.ticket-screen .orders .col.end { + justify-content: flex-end; +} + +.ticket-screen .orders .col.very-narrow { + flex: 0.2; +} + +.ticket-screen .orders .col.narrow { + flex: 0.5; +} + +.ticket-screen .orders .col.wide { + flex: 1.5; +} + +.ticket-screen .order-row:nth-child(odd) { + background: #DDD; +} + +.ticket-screen .order-row:nth-child(even) { + background: white; +} + +.ticket-screen .order-row:hover { + background: rgb(110,200,155); + color: white; +} + +.ticket-screen .pointer { + cursor: pointer; +} + +.ticket-screen .controls { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0px 20px; + border-bottom: dashed 1px rgb(215,215,215); + flex: 0 0 80px; +} + +.ticket-screen .controls button { + font-size: medium; + padding: 12px; + margin-right: 20px; + font-weight: bold; +} + +.ticket-screen .controls button.discard { + background: rgb(230, 230, 230); + border: solid 1px rgb(209, 209, 209); +} + +.ticket-screen .controls button.highlight { + background: #6EC89B; + border: solid 1px #64AF8A; + color: white; +} + +.ticket-screen .pos-search-bar { + vertical-align: middle; + white-space: nowrap; + position: relative; + display: flex; + max-width: 500px; + flex: 1; +} + +.ticket-screen .pos-search-bar .search { + display: flex; + position: relative; + flex: 1; +} + +.ticket-screen .pos-search-bar .search input { + height: 40px; + font-size: medium; + color: #63717f; + padding-left: 40px; + border: solid 1px rgb(209, 209, 209); + flex: 1; +} + +.ticket-screen .pos-search-bar .radius-right { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.ticket-screen .pos-search-bar .radius-left { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.ticket-screen .pos-search-bar .search input:focus { + outline: none; +} + +.ticket-screen .pos-search-bar .search .search-icon { + position: absolute; + left: 15px; + top: 12px; + z-index: 1; + color: #4f5b66; +} + +.ticket-screen .pos-search-bar .search ul { + background: white; + position: absolute; + top: calc(100% + 5px); + right: 2px; + left: 2px; + box-shadow: 1px 1px 3px grey; + font-size: small; +} + +.ticket-screen .pos-search-bar .search li { + color: rgb(1,160,157); + margin: 0.2em 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + padding-left: 35px; +} + +.ticket-screen .pos-search-bar .search li:hover { + background: #DDD; +} + +.ticket-screen .pos-search-bar .search li .field { + font-style: italic; +} + +.ticket-screen .pos-search-bar .search li .term { + font-weight: bold; +} + +.ticket-screen .pos-search-bar .search li.highlight { + background: #DDD; +} + +.ticket-screen .pos-search-bar .filter .down-icon { + position: absolute; + right: 13px; + top: 10px; +} + +.ticket-screen .pos-search-bar .filter { + height: 40px; + background: white; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 20px; + padding-right: 40px; + border: solid 1px rgb(209, 209, 209); + border-left: none; + position: relative; + display: flex; + align-items: center; + max-width: 150px; + font-size: medium; +} + +.ticket-screen .pos-search-bar .filter:hover { + color: #868686; +} + +.ticket-screen .pos-search-bar .filter .options { + display: block; + position: absolute; + top: calc(100% + 5px); + right: 0; + z-index: 1; + box-shadow: 1px 1px 5px grey; + padding: 0.5em 0; + background: white; + color: #555555; +} + +.ticket-screen .pos-search-bar .filter ul.options li { + padding: 0.2em 1.2em; + border-top: none; + display: flex; + justify-content: start; + align-items: center; +} + +.ticket-screen .pos-search-bar .filter ul.options li:hover { + background-color: #DDD; +} + +.ticket-screen .pos-search-bar .search { + display: flex; +} + +.ticket-button { + display: flex; + align-items: center; + padding: 0 15px; + font-size: medium; + color: white; +} + +.ticket-button.highlight { + background: rgb(104,69,95); +} + +.ticket-button:hover { + background: rgb(104,69,95); + cursor: pointer; +} + +.ticket-button .with-badge { + margin-right: 0.7em; + font-size: larger; +} + +[badge] { + position: relative; +} + +[badge]:after { + background: rgb(1,160,157); + border-radius: 10rem; + color: #fff; + content: attr(badge); + font-size: small; + min-width: 20px; + padding: 2px; + position: absolute; + text-align: center; + left: 0.6em; + bottom: 0.6em; +} + +[badge^="-"]:after, +[badge="0"]:after, +[badge=""]:after { + display: none; +} + +/* Product Screen Search Bar */ + +.search-bar-portal { + display: flex; +} + +.search-bar-portal .search-box { + font-size: medium; + position: relative; + display: flex; + align-items: center; + margin: 0 15px; +} + +.search-bar-portal .search-box input { + font-size: medium; + height: 30px; + border: none; + color: #63717f; + padding-left: 38px; + padding-right: 38px; + border-radius: 5px; +} + +.search-bar-portal .search-box input:focus { + outline: none; +} + +.search-bar-portal .search-box .icon { + position: absolute; + left: 0; + margin-left: 12px; + z-index: 1; + color: #4f5b66; +} + +.search-bar-portal .search-box .clear-icon { + position: absolute; + right: 0; + margin-right: 12px; + z-index: 1; + color: #4f5b66; + cursor: pointer; +} 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() }; }); diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml index 39b267e9308..6c2d3c5f46d 100644 --- a/addons/point_of_sale/static/src/xml/Chrome.xml +++ b/addons/point_of_sale/static/src/xml/Chrome.xml @@ -4,15 +4,22 @@ <t t-name="Chrome" owl="1"> <div class="pos" t-att-class="{ 'big-scrollbars': state.uiState.hasBigScrollBars }"> <div class="pos-receipt-print"></div> + <div class="pos-topheader" t-att-class="{ oe_hidden: state.uiState !== 'READY' }"> + <div t-if="tempScreen.isShown" class="block-top-header" /> + <div class="pos-branding" t-if= "!env.isMobile"> + <img class="pos-logo" t-on-click="trigger('toggle-debug-widget')" + src="/point_of_sale/static/src/img/logo.png" alt="Logo" /> + <TicketButton isTicketScreenShown="isTicketScreenShown" /> + </div> + <div class="pos-rightheader"> + <TicketButton isTicketScreenShown="isTicketScreenShown" t-if="env.isMobile" /> + <div class="search-bar-portal" /> + <div class="status-buttons-portal" /> + </div> + </div> <t t-if="state.uiState === 'READY'"> - <div class="pos-topheader"> - <div t-if="tempScreen.isShown" class="block-top-header" /> - <div class="pos-branding"> - <img t-if= "!env.isMobile" class="pos-logo" t-on-click="trigger('toggle-debug-widget')" - src="/point_of_sale/static/src/img/logo.png" alt="Logo" /> - </div> - <div class="pos-rightheader"> - <OrderSelector /> + <Portal target="'.pos .status-buttons-portal'"> + <div class="status-buttons"> <t t-if="!env.isMobile"> <CashierName /> </t> @@ -23,7 +30,7 @@ <SyncNotification /> <HeaderButton /> </div> - </div> + </Portal> <div class="pos-content"> <div class="window"> <div class="subwindow"> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml deleted file mode 100644 index 353ea5f93a4..00000000000 --- a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderSelector.xml +++ /dev/null @@ -1,34 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates id="template" xml:space="preserve"> - - <div class="order-selector" t-name="OrderSelector" owl="1"> - <t t-if="chromeContext.showOrderSelector"> - <span class="orders"> - <t t-foreach="orderList" t-as="order" t-key="order.cid"> - <t t-if="order === env.pos.get_order()"> - <span class="order-button select-order selected"> - <span class="order-sequence"> - <t t-esc="order.sequence_number" /> - </span> - <t t-esc="creationTime(order)" /> - </span> - </t> - <t t-else=""> - <span class="order-button select-order" t-on-click="selectOrder(order)"> - <span class="order-sequence"> - <t t-esc="order.sequence_number" /> - </span> - </span> - </t> - </t> - </span> - <span class="order-button square neworder-button" t-on-click="addNewOrder"> - <i class="fa fa-plus" role="img" aria-label="New order" title="New order" /> - </span> - <span class="order-button square deleteorder-button" t-on-click="deleteCurrentOrder"> - <i class="fa fa-minus" role="img" aria-label="Delete order" title="Delete order" /> - </span> - </t> - </div> - -</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml new file mode 100644 index 00000000000..66d31dd5584 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TicketButton" owl="1"> + <div class="ticket-button" t-att-class="{ highlight: props.isTicketScreenShown }" t-on-click="onClick"> + <div class="with-badge" t-att-badge="count"> + <i class="fa fa-ticket" aria-hidden="true"></i> + </div> + <div>Tickets</div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml new file mode 100644 index 00000000000..a480f169520 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="point_of_sale.SearchBar" owl="1"> + <div class="pos-search-bar"> + <div class="search"> + <span class="search-icon"><i class="fa fa-search"></i></span> + <input class="radius-left" t-att-class="{ 'radius-right': !props.config.filter.show }" + t-model="state.searchInput" t-on-keydown="onKeydown" type="text" t-att-placeholder="placeholder" /> + <ul t-if="state.showSearchFields and state.searchInput" class="fields"> + <t t-foreach="config.searchFields" t-as="value" t-key="value_index"> + <li t-att-class="{ highlight: value_index == state.selectedFieldId }" + t-on-click="onClickSearchField(value_index)"> + <span class="field"> + <t t-esc="value"></t> + </span> + <span>: </span> + <span class="term"> + <t t-esc="state.searchInput"></t> + </span> + </li> + </t> + </ul> + </div> + <div t-if="props.config.filter.show" class="filter radius-right" + t-on-click.stop="state.showFilterOptions = !state.showFilterOptions"> + <span class="down-icon"> + <i class="fa fa-chevron-down" aria-hidden="true"></i> + </span> + <span> + <t t-esc="state.selectedFilter" /> + </span> + <ul t-if="state.showFilterOptions" class="options"> + <t t-foreach="config.filter.options" t-as="option" t-key="option"> + <li t-on-click="selectFilter(option)"> + <t t-esc="option"></t> + </li> + </t> + </ul> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml index c0a49a555b2..6b3f04bb320 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml @@ -3,9 +3,9 @@ <t t-name="ProductsWidget" owl="1"> <div class="products-widget"> - <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" - subcategories="subcategories" /> - + <t t-if="!hasNoCategories"> + <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" /> + </t> <ProductList products="productsToDisplay" searchWord="searchWord" /> </div> </t> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml index c80a2a6d1ef..09c17501d52 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml @@ -3,7 +3,10 @@ <t t-name="ProductsWidgetControlPanel" owl="1"> <div class="products-widget-control"> - <div class="rightpane-header"> + <div class="rightpane-header" t-att-class="{ + 'green-border-bottom': !env.pos.config.iface_display_categ_images, + 'grey-border-bottom': env.pos.config.iface_display_categ_images, + }"> <!-- Breadcrumbs --> <div class="breadcrumbs"> <HomeCategoryBreadcrumb subcategories="props.subcategories" currentCat="props.breadcrumbs[props.breadcrumbs.length - 1]"/> @@ -13,36 +16,33 @@ </t> </t> </div> - <!-- Searchbox --> - <div class="searchbox"> - <input t-ref="search-word-input" placeholder="Search Products" - t-on-keyup="updateSearch" /> - <span class="search-clear left"> - <i class="fa fa-search" /> - </span> - <span class="search-clear right" t-on-click="clearSearch"> - <i class="fa fa-remove" /> - </span> - </div> + <!-- Subcategories --> + <t t-if="props.subcategories.length > 0 and !env.pos.config.iface_display_categ_images and !env.isMobile"> + <t t-foreach="props.subcategories" t-as="category" t-key="category.id"> + <CategorySimpleButton category="category" /> + </t> + </t> </div> - <!-- Subcategories --> - <t t-if="props.subcategories.length > 0 && !env.isMobile"> + <t t-if="props.subcategories.length > 0 and env.pos.config.iface_display_categ_images and !env.isMobile"> <div class="categories"> <div class="category-list-scroller"> - <div class="category-list" - t-att-class="{simple: !env.pos.config.iface_display_categ_images}"> + <div class="category-list"> <t t-foreach="props.subcategories" t-as="category" t-key="category.id"> - <t t-if="env.pos.config.iface_display_categ_images"> - <CategoryButton category="category" /> - </t> - <t t-else=""> - <CategorySimpleButton category="category" /> - </t> + <CategoryButton category="category" /> </t> </div> </div> </div> </t> + <Portal target="'.pos .search-bar-portal'"> + <div class="search-box"> + <span class="icon"><i class="fa fa-search"></i></span> + <span t-on-click="clearSearch" class="clear-icon"> + <i class="fa fa-times" aria-hidden="true"></i> + </span> + <input t-ref="search-word-input" type="text" placeholder="Search Products..." t-on-keyup="updateSearch" /> + </div> + </Portal> </div> </t> diff --git a/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml new file mode 100644 index 00000000000..2aa890e029d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TicketScreen" owl="1"> + <div class="ticket-screen screen"> + <div class="screen-content"> + <div class="controls"> + <div class="buttons"> + <button t-if="showNewTicketButton" class="highlight" t-on-click="createNewOrder">New Ticket</button> + <button class="discard" t-on-click="trigger('close-screen')">Discard</button> + </div> + <SearchBar config="searchBarConfig" placeholder="'Search Tickets...'" /> + </div> + <div class="orders"> + <div class="header-row"> + <div class="col start wide">Date</div> + <div class="col start wide">Receipt Number</div> + <div class="col start">Customer</div> + <div class="col start">Employee</div> + <div class="col end">Total</div> + <div class="col start narrow">Status</div> + <div class="col center very-narrow" name="delete"></div> + </div> + <t t-foreach="filteredOrderList" t-as="order" t-key="order.cid"> + <div class="order-row pointer" t-on-click="selectOrder(order)"> + <div class="col start wide"> + <t t-esc="getDate(order)"></t> + </div> + <div class="col start wide"> + <t t-esc="order.name"></t> + </div> + <div class="col start"> + <t t-esc="getCustomer(order)"></t> + </div> + <div class="col start"> + <t t-esc="getEmployee(order)"></t> + </div> + <div class="col end"> + <t t-esc="getTotal(order)"></t> + </div> + <div class="col start narrow"> + <t t-esc="getStatus(order)"></t> + </div> + <div t-if="!hideDeleteButton(order)" class="col center very-narrow delete-button" name="delete" t-on-click.stop="deleteOrder(order)"> + <i class="fa fa-trash" aria-hidden="true"></i> + </div> + <div t-else="" class="col center very-narrow delete-button"></div> + </div> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/tests/tours/Chrome.tour.js b/addons/point_of_sale/static/tests/tours/Chrome.tour.js index 7789af6e7da..438925518f9 100644 --- a/addons/point_of_sale/static/tests/tours/Chrome.tour.js +++ b/addons/point_of_sale/static/tests/tours/Chrome.tour.js @@ -4,6 +4,7 @@ odoo.define('point_of_sale.tour.Chrome', function (require) { const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); const { Chrome } = require('point_of_sale.tour.ChromeTourMethods'); const { getSteps, startSteps } = require('point_of_sale.tour.utils'); var Tour = require('web_tour.tour'); @@ -13,62 +14,75 @@ odoo.define('point_of_sale.tour.Chrome', function (require) { // Order 1 is at Product Screen ProductScreen.do.clickHomeCategory(); ProductScreen.exec.addOrderline('Desk Pad', '1', '2'); + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0001', 'Ongoing'); // Order 2 is at Payment Screen - Chrome.do.newOrder(); + TicketScreen.do.clickNewTicket(); ProductScreen.exec.addOrderline('Monitor Stand', '3', '4'); ProductScreen.do.clickPayButton(); PaymentScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0002', 'Payment'); // Order 3 is at Receipt Screen - Chrome.do.newOrder(); + TicketScreen.do.clickNewTicket(); ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6'); ProductScreen.do.clickPayButton(); PaymentScreen.do.clickPaymentMethod('Bank'); PaymentScreen.do.clickValidate(); ReceiptScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0003', 'Receipt'); // Select order 1, should be at Product Screen - Chrome.do.selectOrder('1'); + TicketScreen.do.selectOrder('-0001'); ProductScreen.check.productIsDisplayed('Desk Pad'); ProductScreen.check.selectedOrderlineHas('Desk Pad', '1.0', '2.0'); // Select order 2, should be at Payment Screen - Chrome.do.selectOrder('2'); + Chrome.do.clickTicketButton(); + TicketScreen.do.selectOrder('-0002'); PaymentScreen.check.emptyPaymentlines('12.0'); PaymentScreen.check.validateButtonIsHighlighted(false); // Select order 3, should be at Receipt Screen - Chrome.do.selectOrder('3'); + Chrome.do.clickTicketButton(); + TicketScreen.do.selectOrder('-0003'); ReceiptScreen.check.changeIs('0.0'); // Pay order 1, with change - Chrome.do.selectOrder('1'); + Chrome.do.clickTicketButton(); + TicketScreen.do.selectOrder('-0001'); ProductScreen.do.clickPayButton(); PaymentScreen.do.clickPaymentMethod('Cash'); PaymentScreen.do.pressNumpad('2 0'); PaymentScreen.do.clickValidate(); ReceiptScreen.check.changeIs('18.0'); + // Order 1 now should have Receipt status + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0001', 'Receipt'); + // Select order 3, should still be at Receipt Screen // but change should be different. - Chrome.do.selectOrder('3'); + TicketScreen.do.selectOrder('-0003'); ReceiptScreen.check.changeIs('0.0'); // click next screen on order 3 // then delete the new empty order ReceiptScreen.do.clickNextOrder(); ProductScreen.check.orderIsEmpty(); - Chrome.do.deleteOrder(); + Chrome.do.clickTicketButton(); + TicketScreen.do.deleteOrder('-0004'); + TicketScreen.do.deleteOrder('-0001'); - // Order 2 should be the current order - // Deleting it should open a popup, confirm it. - Chrome.do.deleteOrder(); + // After deleting order 1 above, order 2 became + // the 2nd-row order and it has payment status + TicketScreen.check.nthRowContains(2, 'Payment') + TicketScreen.do.deleteOrder('-0002'); Chrome.do.confirmPopup(); - - // Now left with order 1 in payment screen - // go next screen - ReceiptScreen.do.clickNextOrder(); + TicketScreen.do.clickNewTicket(); // Invoice an order ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6'); diff --git a/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js index 44a09579fe3..e41b4d3b687 100644 --- a/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js +++ b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js @@ -5,6 +5,7 @@ odoo.define('point_of_sale.tour.OrderManagementScreen', function (require) { const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); const { Chrome } = require('point_of_sale.tour.ChromeTourMethods'); const { makeFullOrder } = require('point_of_sale.tour.CompositeTourMethods'); const { getSteps, startSteps } = require('point_of_sale.tour.utils'); @@ -70,11 +71,13 @@ odoo.define('point_of_sale.tour.OrderManagementScreen', function (require) { // Add 2 orders, they should appear in order management screen // order 0006 - Chrome.do.newOrder(); + Chrome.do.clickTicketButton(); + TicketScreen.do.clickNewTicket(); ProductScreen.exec.addOrderline('Whiteboard Pen', '66', '6'); // order 0007, should be at payment screen - Chrome.do.newOrder(); + Chrome.do.clickTicketButton(); + TicketScreen.do.clickNewTicket(); ProductScreen.exec.addOrderline('Monitor Stand', '55', '5'); ProductScreen.do.clickCustomerButton(); ClientListScreen.exec.setClient('Azure Interior'); diff --git a/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js new file mode 100644 index 00000000000..4bf05d02fdd --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js @@ -0,0 +1,54 @@ +odoo.define('point_of_sale.tour.TicketScreen', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); + const { Chrome } = require('point_of_sale.tour.ChromeTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + startSteps(); + + ProductScreen.do.clickHomeCategory(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '2'); + ProductScreen.do.clickCustomerButton(); + ProductScreen.do.clickCustomer('Nicole Ford'); + ProductScreen.do.clickSetCustomer(); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains(2, 'Nicole Ford'); + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '3'); + ProductScreen.do.clickCustomerButton(); + ProductScreen.do.clickCustomer('Brandon Freeman'); + ProductScreen.do.clickSetCustomer(); + ProductScreen.do.clickPayButton(); + PaymentScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains(3, 'Brandon Freeman'); + TicketScreen.do.clickNewTicket(); + ProductScreen.exec.addOrderline('Desk Pad', '1', '4'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Bank'); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains(4, 'Receipt'); + TicketScreen.do.selectFilter('Receipt'); + TicketScreen.check.nthRowContains(2, 'Receipt'); + TicketScreen.do.selectFilter('Payment'); + TicketScreen.check.nthRowContains(2, 'Payment'); + TicketScreen.do.selectFilter('Ongoing'); + TicketScreen.check.nthRowContains(2, 'Ongoing'); + TicketScreen.do.selectFilter('All'); + TicketScreen.check.nthRowContains(4, 'Receipt'); + TicketScreen.do.search('Customer', 'Nicole'); + TicketScreen.check.nthRowContains(2, 'Nicole'); + TicketScreen.do.search('Customer', 'Brandon'); + TicketScreen.check.nthRowContains(2, 'Brandon'); + TicketScreen.do.search('Receipt Number', '-0003'); + TicketScreen.check.nthRowContains(2, 'Receipt'); + + Tour.register('TicketScreenTour', { test: true, url: '/pos/web' }, getSteps()); +}); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js index a5bd006fad1..58f3bfa4215 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js @@ -4,30 +4,6 @@ odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) { const { createTourMethods } = require('point_of_sale.tour.utils'); class Do { - newOrder() { - return [ - { - content: 'new order', - trigger: '.order-selector .neworder-button', - }, - ]; - } - deleteOrder() { - return [ - { - content: 'delete current order', - trigger: '.order-selector .deleteorder-button', - }, - ]; - } - selectOrder(orderSequence) { - return [ - { - content: `select order '${orderSequence}'`, - trigger: `.order-selector .order-sequence:contains("${orderSequence}")`, - }, - ]; - } confirmPopup() { return [ { @@ -49,6 +25,17 @@ odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) { }, ]; } + clickTicketButton() { + return [ + { + trigger: '.pos-topheader .ticket-button > div:contains("Tickets")', + }, + { + trigger: '.subwindow .ticket-screen', + run: () => {}, + }, + ]; + } } return createTourMethods('Chrome', Do); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js index 2bb4d025ae0..dab5ae15683 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js @@ -31,7 +31,7 @@ odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) { return [ { content: `selecting '${name}' subcategory`, - trigger: `.category-list .category-simple-button:contains("${name}")`, + trigger: `.products-widget > .products-widget-control .category-simple-button:contains("${name}")`, }, { content: `'${name}' subcategory selected`, diff --git a/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js new file mode 100644 index 00000000000..9fa0c9cd558 --- /dev/null +++ b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js @@ -0,0 +1,100 @@ +odoo.define('point_of_sale.tour.TicketScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickNewTicket() { + return [{ trigger: '.ticket-screen .highlight' }]; + } + clickDiscard() { + return [{ trigger: '.ticket-screen button.discard' }]; + } + selectOrder(orderName) { + return [ + { + trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}")`, + }, + ]; + } + deleteOrder(orderName) { + return [ + { + trigger: `.ticket-screen .orders > .order-row > .col:contains("${orderName}") ~ .col[name="delete"]`, + }, + ]; + } + selectFilter(name) { + return [ + { + trigger: `.pos-search-bar .filter`, + }, + { + trigger: `.pos-search-bar .filter ul`, + run: () => {}, + }, + { + trigger: `.pos-search-bar .filter ul li:contains("${name}")`, + }, + ]; + } + search(field, searchWord) { + return [ + { + trigger: '.pos-search-bar input', + run: `text ${searchWord}`, + }, + { + /** + * Manually trigger keydown event to show the search field list + * because the previous step do not trigger keydown event. + */ + trigger: '.pos-search-bar input', + run: function () { + document + .querySelector('.pos-search-bar input') + .dispatchEvent(new KeyboardEvent('keydown', { key: '' })); + }, + }, + { + trigger: `.pos-search-bar .search ul li:contains("${field}")`, + }, + ]; + } + } + + class Check { + checkStatus(orderName, status) { + return [ + { + trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}") ~ .col:nth-child(6):contains(${status})`, + run: () => {}, + }, + ]; + } + /** + * Check if the nth row contains the given string. + * Note that 1st row is the header-row. + */ + nthRowContains(n, string) { + return [ + { + trigger: `.ticket-screen .orders > .order-row:nth-child(${n}):contains("${string}")`, + run: () => {}, + }, + ]; + } + noNewTicketButton() { + return [ + { + trigger: '.ticket-screen .controls .buttons:nth-child(1):has(.discard)', + run: () => {}, + }, + ]; + } + } + + class Execute {} + + return createTourMethods('TicketScreen', Do, Check, Execute); +}); diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_env.js b/addons/point_of_sale/static/tests/unit/helpers/test_env.js index 81ce9ce3ac4..c4b0b3ecef6 100644 --- a/addons/point_of_sale/static/tests/unit/helpers/test_env.js +++ b/addons/point_of_sale/static/tests/unit/helpers/test_env.js @@ -11,6 +11,9 @@ odoo.define('point_of_sale.test_env', async function (require) { const makeTestEnvironment = require('web.test_env'); const env = require('web.env'); const models = require('point_of_sale.models'); + const Registries = require('point_of_sale.Registries'); + + Registries.Component.add(owl.misc.Portal); await env.session.is_bound; const pos = new models.PosModel({ diff --git a/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js index c1ad31804e6..a0df97fdbd4 100644 --- a/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js +++ b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js @@ -49,53 +49,6 @@ odoo.define('point_of_sale.tests.ChromeWidgets', function (require) { parent.destroy(); }); - QUnit.test('OrderSelector', async function (assert) { - assert.expect(4); - - class Parent extends PopupControllerMixin(PosComponent) {} - Parent.env = makePosTestEnv(); - Parent.env.chrome = new owl.Context({ showOrderSelector: true }); - Parent.template = xml/* html */ ` - <div> - <OrderSelector></OrderSelector> - <t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" /> - </div> - `; - - const pos = Parent.env.pos; - - const parent = new Parent(); - await parent.mount(testUtils.prepareTarget()); - - const plusButton = parent.el.querySelector('.neworder-button'); - const minusButton = parent.el.querySelector('.deleteorder-button'); - - await testUtils.dom.click(plusButton); - await testUtils.nextTick(); - assert.strictEqual(2, parent.el.querySelectorAll('.order-sequence').length); - await testUtils.dom.click(minusButton); - await testUtils.nextTick(); - assert.strictEqual(1, parent.el.querySelectorAll('.order-sequence').length); - - const product = Object.values(pos.db.product_by_id)[0]; - pos.get_order().add_product(product); - - // try deleting the order with orderline - await testUtils.dom.click(minusButton); - await testUtils.nextTick(); - - // confirm popup should appear - assert.ok(parent.el.querySelector('.popup')); - // confirm deletion - await testUtils.dom.click(parent.el.querySelector('.confirm')); - await testUtils.nextTick(); - // there should be new order created - assert.strictEqual(1, parent.el.querySelectorAll('.order-sequence').length); - - parent.unmount(); - parent.destroy(); - }); - QUnit.test('SyncNotification', async function (assert) { assert.expect(5); diff --git a/addons/point_of_sale/static/tests/unit/test_ProductScreen.js b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js index 042048c72eb..bc717da38fe 100644 --- a/addons/point_of_sale/static/tests/unit/test_ProductScreen.js +++ b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js @@ -244,8 +244,10 @@ odoo.define('point_of_sale.tests.ProductScreen', function (require) { } Parent.env = makePosTestEnv(); Parent.template = xml/* html */ ` - <div> - <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" /> + <div class="pos"> + <div class="search-bar-portal"> + <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" /> + </div> </div> `; @@ -337,7 +339,7 @@ odoo.define('point_of_sale.tests.ProductScreen', function (require) { }); }; - const inputEl = parent.el.querySelector('.searchbox input'); + const inputEl = parent.el.querySelector('.search-box input'); await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'A' }); // Triggering keyup event doesn't type the key to the input // so we manually assign the value of the input. @@ -358,7 +360,7 @@ odoo.define('point_of_sale.tests.ProductScreen', function (require) { assert.verifySteps(['ABCD']); // clear the search bar - await testUtils.dom.click(parent.el.querySelector('.search-clear.right')); + await testUtils.dom.click(parent.el.querySelector('.search-box .clear-icon')); await testUtils.nextTick(); assert.verifySteps(['cleared']); assert.ok(inputEl.value === '', 'value of the input element should be empty'); |
