diff options
Diffstat (limited to 'addons/point_of_sale/static/src/js/Screens')
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; +}); |
