diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static/src/js/Misc | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static/src/js/Misc')
7 files changed, 697 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js new file mode 100644 index 00000000..2ebdce20 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js @@ -0,0 +1,62 @@ +odoo.define('point_of_sale.AbstractReceiptScreen', function (require) { + 'use strict'; + + const { useRef } = owl.hooks; + const { nextFrame } = require('point_of_sale.utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * This relies on the assumption that there is a reference to + * `order-receipt` so it is important to declare a `t-ref` to + * `order-receipt` in the template of the Component that extends + * this abstract component. + */ + class AbstractReceiptScreen extends PosComponent { + constructor() { + super(...arguments); + this.orderReceipt = useRef('order-receipt'); + } + async _printReceipt() { + if (this.env.pos.proxy.printer) { + const printResult = await this.env.pos.proxy.printer.print_receipt(this.orderReceipt.el.outerHTML); + if (printResult.successful) { + return true; + } else { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: printResult.message.title, + body: 'Do you want to print using the web printer?', + }); + if (confirmed) { + // We want to call the _printWeb when the popup is fully gone + // from the screen which happens after the next animation frame. + await nextFrame(); + return await this._printWeb(); + } + return false; + } + } else { + return await this._printWeb(); + } + } + async _printWeb() { + try { + window.print(); + return true; + } catch (err) { + await this.showPopup('ErrorPopup', { + title: this.env._t('Printing is not supported on some browsers'), + body: this.env._t( + 'Printing is not supported on some browsers due to no default printing protocol ' + + 'is available. It is possible to print your tickets by making use of an IoT Box.' + ), + }); + return false; + } + } + } + + Registries.Component.add(AbstractReceiptScreen); + + return AbstractReceiptScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/Draggable.js b/addons/point_of_sale/static/src/js/Misc/Draggable.js new file mode 100644 index 00000000..cbb1eba8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/Draggable.js @@ -0,0 +1,142 @@ +odoo.define('point_of_sale.Draggable', function(require) { + 'use strict'; + + const { useExternalListener } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * Wrap an element or a component with { position: absolute } to make it + * draggable around the limitArea or the nearest positioned ancestor. + * + * e.g. + * ``` + * <div class="limit-area"> + * <Draggable limitArea="'.limit-area'"> + * <div class="popup"> + * <header class="drag-handle"></header> + * </div> + * <div class="popup body"></div> + * </Draggable> + * </div> + * ``` + * + * In the above snippet, if the popup div is { position: absolute }, + * then it becomes draggable around the .limit-area element if it is dragged + * thru its Header -- because of the .drag-handle element. + * + * @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }` + */ + class Draggable extends PosComponent { + constructor() { + super(...arguments); + this.isDragging = false; + this.dx = 0; + this.dy = 0; + // drag with mouse + useExternalListener(document, 'mousemove', this.move); + useExternalListener(document, 'mouseup', this.endDrag); + // drag with touch + useExternalListener(document, 'touchmove', this.move); + useExternalListener(document, 'touchend', this.endDrag); + + useListener('mousedown', '.drag-handle', this.startDrag); + useListener('touchstart', '.drag-handle', this.startDrag); + } + mounted() { + this.limitArea = this.props.limitArea + ? document.querySelector(this.props.limitArea) + : this.el.offsetParent; + this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect(); + if (this.limitArea === this.el.offsetParent) { + this.limitLeft = 0; + this.limitTop = 0; + this.limitRight = this.limitAreaBoundingRect.width; + this.limitBottom = this.limitAreaBoundingRect.height; + } else { + this.limitLeft = -this.el.offsetParent.offsetLeft; + this.limitTop = -this.el.offsetParent.offsetTop; + this.limitRight = + this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft; + this.limitBottom = + this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop; + } + this.limitAreaWidth = this.limitAreaBoundingRect.width; + this.limitAreaHeight = this.limitAreaBoundingRect.height; + + // absolutely position the element then remove the transform. + const elBoundingRect = this.el.getBoundingClientRect(); + this.el.style.top = `${elBoundingRect.top}px`; + this.el.style.left = `${elBoundingRect.left}px`; + this.el.style.transform = 'none'; + } + startDrag(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { x, y } = this._getEventLoc(realEvent); + this.isDragging = true; + this.dx = this.el.offsetLeft - x; + this.dy = this.el.offsetTop - y; + event.stopPropagation(); + } + move(event) { + if (this.isDragging) { + const { x: pointerX, y: pointerY } = this._getEventLoc(event); + const posLeft = this._getPosLeft(pointerX, this.dx); + const posTop = this._getPosTop(pointerY, this.dy); + this.el.style.left = `${posLeft}px`; + this.el.style.top = `${posTop}px`; + } + } + endDrag() { + if (this.isDragging) { + this.isDragging = false; + this.trigger('drag-end', { + loc: { top: this.el.offsetTop, left: this.el.offsetLeft }, + }); + } + } + _getEventLoc(event) { + let coordX, coordY; + if (event.touches && event.touches[0]) { + coordX = event.touches[0].clientX; + coordY = event.touches[0].clientY; + } else { + coordX = event.clientX; + coordY = event.clientY; + } + return { + x: coordX, + y: coordY, + }; + } + _getPosLeft(pointerX, dx) { + const posLeft = pointerX + dx; + if (posLeft < this.limitLeft) { + return this.limitLeft; + } else if (posLeft > this.limitRight - this.el.offsetWidth) { + return this.limitRight - this.el.offsetWidth; + } + return posLeft; + } + _getPosTop(pointerY, dy) { + const posTop = pointerY + dy; + if (posTop < this.limitTop) { + return this.limitTop; + } else if (posTop > this.limitBottom - this.el.offsetHeight) { + return this.limitBottom - this.el.offsetHeight; + } + return posTop; + } + } + Draggable.template = 'Draggable'; + + Registries.Component.add(Draggable); + + return Draggable; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js new file mode 100644 index 00000000..e2f2148b --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.IndependentToOrderScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + + class IndependentToOrderScreen extends PosComponent { + /** + * Alias the forceTriggerSelectedOrder method as it also + * means 'closing' this screen. + */ + close() { + this.forceTriggerSelectedOrder(); + } + forceTriggerSelectedOrder() { + // Calling this method forcefully trigger change + // on the selectedOrder attribute, which then shows the screen of the + // current order, essentially closing this screen. + this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order()); + } + } + + return IndependentToOrderScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js new file mode 100644 index 00000000..024a77b3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js @@ -0,0 +1,39 @@ +odoo.define('point_of_sale.MobileOrderWidget', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class MobileOrderWidget extends PosComponent { + constructor() { + super(...arguments); + this.pane = this.props.pane; + this.update(); + } + get order() { + return this.env.pos.get_order(); + } + mounted() { + this.order.on('change', () => { + this.update(); + this.render(); + }); + this.order.orderlines.on('change', () => { + this.update(); + this.render(); + }); + } + update() { + const total = this.order ? this.order.get_total_with_tax() : 0; + const tax = this.order ? total - this.order.get_total_without_tax() : 0; + this.total = this.env.pos.format_currency(total); + this.items_number = this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0; + } + } + + MobileOrderWidget.template = 'MobileOrderWidget'; + + Registries.Component.add(MobileOrderWidget); + + return MobileOrderWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/NotificationSound.js b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js new file mode 100644 index 00000000..540e84f1 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js @@ -0,0 +1,19 @@ +odoo.define('point_of_sale.NotificationSound', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class NotificationSound extends PosComponent { + constructor() { + super(...arguments); + useListener('ended', () => (this.props.sound.src = null)); + } + } + NotificationSound.template = 'NotificationSound'; + + Registries.Component.add(NotificationSound); + + return NotificationSound; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js new file mode 100644 index 00000000..8e25f601 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js @@ -0,0 +1,297 @@ +odoo.define('point_of_sale.NumberBuffer', function(require) { + 'use strict'; + + const { Component } = owl; + const { EventBus } = owl.core; + const { onMounted, onWillUnmount, useExternalListener } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const { parse } = require('web.field_utils'); + const { BarcodeEvents } = require('barcodes.BarcodeEvents'); + const { _t } = require('web.core'); + const { Gui } = require('point_of_sale.Gui'); + + const INPUT_KEYS = new Set( + ['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split('')) + ); + const CONTROL_KEYS = new Set(['Enter', 'Esc']); + const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]); + const getDefaultConfig = () => ({ + decimalPoint: false, + triggerAtEnter: false, + triggerAtEsc: false, + triggerAtInput: false, + nonKeyboardInputEvent: false, + useWithBarcode: false, + }); + + /** + * This is a singleton. + * + * Only one component can `use` the buffer at a time. + * This is done by keeping track of each component (and its + * corresponding state and config) using a stack (bufferHolderStack). + * The component on top of the stack is the one that currently + * `holds` the buffer. + * + * When the current component is unmounted, the top of the stack + * is popped and NumberBuffer is set up again for the new component + * on top of the stack. + * + * Usage + * ===== + * - Activate in the construction of root component. `NumberBuffer.activate()` + * - Use the buffer in a child component by calling `NumberBuffer.use(<config>)` + * in the constructor of the child component. + * - The component that `uses` the buffer has access to the following instance + * methods of the NumberBuffer: + * - get() + * - set(val) + * - reset() + * - getFloat() + * - capture() + * + * Note + * ==== + * - No need to instantiate as it is a singleton created before exporting in this module. + * + * Possible Improvements + * ===================== + * - Relieve the buffer from responsibility of handling `Enter` and other control keys. + * - Make the constants (ALLOWED_KEYS, etc.) more configurable. + * - Write more integration tests. NumberPopup can be used as test component. + */ + class NumberBuffer extends EventBus { + constructor() { + super(); + this.isReset = false; + this.bufferHolderStack = []; + } + /** + * @returns {String} value of the buffer, e.g. '-95.79' + */ + get() { + return this.state ? this.state.buffer : null; + } + /** + * Takes a string that is convertible to float, and set it as + * value of the buffer. e.g. val = '2.99'; + * + * @param {String} val + */ + set(val) { + this.state.buffer = !isNaN(parseFloat(val)) ? val : ''; + this.trigger('buffer-update', this.state.buffer); + } + /** + * Resets the buffer to empty string. + */ + reset() { + this.isReset = true; + this.state.buffer = ''; + this.trigger('buffer-update', this.state.buffer); + } + /** + * Calling this function, we immediately invoke the `handler` method + * that handles the contents of the input events buffer (`eventsBuffer`). + * This is helpful when we don't want to wait for the timeout that + * is supposed to invoke the handler. + */ + capture() { + if (this.handler) { + clearTimeout(this._timeout); + this.handler(); + delete this.handler; + } + } + /** + * @returns {number} float equivalent of the value of buffer + */ + getFloat() { + return parse.float(this.get()); + } + /** + * Add keyup listener to window via the useExternalListener hook. + * When the component calling this is unmounted, the listener is also + * removed from window. + */ + activate() { + this.defaultDecimalPoint = _t.database.parameters.decimal_point; + useExternalListener(window, 'keyup', this._onKeyboardInput.bind(this)); + } + /** + * @param {Object} config Use to setup the buffer + * @param {String|null} config.decimalPoint The decimal character. + * @param {String|null} config.triggerAtEnter Event triggered when 'Enter' key is pressed. + * @param {String|null} config.triggerAtEsc Event triggered when 'Esc' key is pressed. + * @param {String|null} config.triggerAtInput Event triggered for every accepted input. + * @param {String|null} config.nonKeyboardInputEvent Also listen to a non-keyboard input event + * that carries a payload of { key }. The key is checked if it is a valid input. If valid, + * the number buffer is modified just as it is modified when a keyboard key is pressed. + * @param {Boolean} config.useWithBarcode Whether this buffer is used with barcode. + * @emits config.triggerAtEnter when 'Enter' key is pressed. + * @emits config.triggerAtEsc when 'Esc' key is pressed. + * @emits config.triggerAtInput when an input is accepted. + */ + use(config) { + this.eventsBuffer = []; + const currentComponent = Component.current; + config = Object.assign(getDefaultConfig(), config); + onMounted(() => { + this.bufferHolderStack.push({ + component: currentComponent, + state: config.state ? config.state : { buffer: '' }, + config, + }); + this._setUp(); + }); + onWillUnmount(() => { + this.bufferHolderStack.pop(); + this._setUp(); + }); + // Add listener that accepts non keyboard inputs + if (typeof config.nonKeyboardInputEvent === 'string') { + useListener(config.nonKeyboardInputEvent, this._onNonKeyboardInput.bind(this)); + } + } + get _currentBufferHolder() { + return this.bufferHolderStack[this.bufferHolderStack.length - 1]; + } + _setUp() { + if (!this._currentBufferHolder) return; + const { component, state, config } = this._currentBufferHolder; + this.component = component; + this.state = state; + this.config = config; + this.decimalPoint = config.decimalPoint || this.defaultDecimalPoint; + this.maxTimeBetweenKeys = this.config.useWithBarcode + ? BarcodeEvents.max_time_between_keys_in_ms + : 0; + } + _onKeyboardInput(event) { + return this._bufferEvents(this._onInput(event => event.key))(event); + } + _onNonKeyboardInput(event) { + return this._bufferEvents(this._onInput(event => event.detail.key))(event); + } + _bufferEvents(handler) { + return event => { + if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return; + clearTimeout(this._timeout); + this.eventsBuffer.push(event); + this._timeout = setTimeout(handler, this.maxTimeBetweenKeys); + this.handler = handler + }; + } + _onInput(keyAccessor) { + return () => { + if (this.eventsBuffer.length <= 2) { + // Check first the buffer if its contents are all valid + // number input. + for (let event of this.eventsBuffer) { + if (!ALLOWED_KEYS.has(keyAccessor(event))) { + this.eventsBuffer = []; + return; + } + } + // At this point, all the events in buffer + // contains number input. It's now okay to handle + // each input. + for (let event of this.eventsBuffer) { + this._handleInput(keyAccessor(event)); + event.preventDefault(); + event.stopPropagation(); + } + } + this.eventsBuffer = []; + }; + } + _handleInput(key) { + if (key === 'Enter' && this.config.triggerAtEnter) { + this.component.trigger(this.config.triggerAtEnter, this.state); + } else if (key === 'Esc' && this.config.triggerAtEsc) { + this.component.trigger(this.config.triggerAtEsc, this.state); + } else if (INPUT_KEYS.has(key)) { + this._updateBuffer(key); + if (this.config.triggerAtInput) + this.component.trigger(this.config.triggerAtInput, { buffer: this.state.buffer, key }); + } + } + /** + * Updates the current buffer state using the given input. + * @param {String} input valid input + */ + _updateBuffer(input) { + const isEmpty = val => { + return val === '' || val === null; + }; + if (input === undefined || input === null) return; + let isFirstInput = isEmpty(this.state.buffer); + if (input === ',' || input === '.') { + if (isFirstInput) { + this.state.buffer = '0' + this.decimalPoint; + } else if (!this.state.buffer.length || this.state.buffer === '-') { + this.state.buffer += '0' + this.decimalPoint; + } else if (this.state.buffer.indexOf(this.decimalPoint) < 0) { + this.state.buffer = this.state.buffer + this.decimalPoint; + } + } else if (input === 'Delete') { + if (this.isReset) { + this.state.buffer = ''; + this.isReset = false; + return; + } + this.state.buffer = isEmpty(this.state.buffer) ? null : ''; + } else if (input === 'Backspace') { + if (this.isReset) { + this.state.buffer = ''; + this.isReset = false; + return; + } + const buffer = this.state.buffer; + if (isEmpty(buffer)) { + this.state.buffer = null; + } else { + const nCharToRemove = buffer[buffer.length - 1] === this.decimalPoint ? 2 : 1; + this.state.buffer = buffer.substring(0, buffer.length - nCharToRemove); + } + } else if (input === '+') { + if (this.state.buffer[0] === '-') { + this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length); + } + } else if (input === '-') { + if (isFirstInput) { + this.state.buffer = '-0'; + } else if (this.state.buffer[0] === '-') { + this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length); + } else { + this.state.buffer = '-' + this.state.buffer; + } + } else if (input[0] === '+' && !isNaN(parseFloat(input))) { + // when input is like '+10', '+50', etc + const inputValue = parse.float(input.slice(1)); + const currentBufferValue = this.state.buffer ? parse.float(this.state.buffer) : 0; + this.state.buffer = this.component.env.pos.formatFixed( + inputValue + currentBufferValue + ); + } else if (!isNaN(parseInt(input, 10))) { + if (isFirstInput) { + this.state.buffer = '' + input; + } else if (this.state.buffer.length > 12) { + Gui.playSound('bell'); + } else { + this.state.buffer += input; + } + } + if (this.state.buffer === '-') { + this.state.buffer = ''; + } + // once an input is accepted and updated the buffer, + // the buffer should not be in reset state anymore. + this.isReset = false; + + this.trigger('buffer-update', this.state.buffer); + } + } + + return new NumberBuffer(); +}); diff --git a/addons/point_of_sale/static/src/js/Misc/SearchBar.js b/addons/point_of_sale/static/src/js/Misc/SearchBar.js new file mode 100644 index 00000000..e9f56fea --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/SearchBar.js @@ -0,0 +1,115 @@ +odoo.define('point_of_sale.SearchBar', function (require) { + 'use strict'; + + const { useState, useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * This is a simple configurable search bar component. It has search fields + * and selection filter. Search fields allow the users to specify the type + * of their searches. The filter is a dropdown menu for selection. Depending on + * user's action, this component emits corresponding event with the action + * information (payload). + * + * TODO: This component can be made more generic and be able to replace + * all the search bars across pos ui. + * + * @prop {{ + * config: { + * searchFields: string[], + * filter: { show: boolean, options: string[] } + * }, + * placeholder: string, + * }} + * @emits search @payload { fieldValue: string, searchTerm: '' } + * @emits filter-selected @payload { filter: string } + * + * NOTE: The payload of the emitted event is accessible via the `detail` + * field of the event. + */ + class SearchBar extends PosComponent { + constructor() { + super(...arguments); + this.config = this.props.config; + this.state = useState({ + searchInput: '', + selectedFieldId: this.config.searchFields.length ? 0 : null, + showSearchFields: false, + showFilterOptions: false, + selectedFilter: this.config.filter.options[0] || this.env._t('Select'), + }); + useExternalListener(window, 'click', this._hideOptions); + } + selectFilter(option) { + this.state.selectedFilter = option; + this.trigger('filter-selected', { filter: this.state.selectedFilter }); + } + get placeholder() { + return this.props.placeholder; + } + /** + * When vertical arrow keys are pressed, select fields for searching. + * When enter key is pressed, trigger search event if there is searchInput. + */ + onKeydown(event) { + if (['ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + this.state.selectedFieldId = this._fieldIdToSelect(event.key); + } else if (event.key === 'Enter') { + this.trigger('search', { + fieldValue: this.config.searchFields[this.state.selectedFieldId], + searchTerm: this.state.searchInput, + }); + this.state.showSearchFields = false; + } else { + if (this.state.selectedFieldId === null && this.config.searchFields.length) { + this.state.selectedFieldId = 0; + } + this.state.showSearchFields = true; + } + } + /** + * Called when a search field is clicked. + */ + onClickSearchField(id) { + this.state.showSearchFields = false; + this.trigger('search', { + fieldValue: this.config.searchFields[id], + searchTerm: this.state.searchInput, + }); + } + /** + * Given an arrow key, return the next selectedFieldId. + * E.g. If the selectedFieldId is 1 and ArrowDown is pressed, return 2. + * + * @param {string} key vertical arrow key + */ + _fieldIdToSelect(key) { + const length = this.config.searchFields.length; + if (!length) return null; + if (this.state.selectedFieldId === null) return 0; + const current = this.state.selectedFieldId || length; + return (current + (key === 'ArrowDown' ? 1 : -1)) % length; + } + _hideOptions() { + this.state.showFilterOptions = false; + this.state.showSearchFields = false; + } + } + SearchBar.template = 'point_of_sale.SearchBar'; + SearchBar.defaultProps = { + config: { + searchFields: [], + filter: { + show: false, + options: [], + }, + }, + placeholder: 'Search ...', + }; + + Registries.Component.add(SearchBar); + + return SearchBar; +}); |
