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/pos_restaurant/static/src/js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/pos_restaurant/static/src/js')
28 files changed, 2734 insertions, 0 deletions
diff --git a/addons/pos_restaurant/static/src/js/Chrome.js b/addons/pos_restaurant/static/src/js/Chrome.js new file mode 100644 index 00000000..949082f1 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Chrome.js @@ -0,0 +1,99 @@ +odoo.define('pos_restaurant.chrome', function (require) { + 'use strict'; + + const Chrome = require('point_of_sale.Chrome'); + const Registries = require('point_of_sale.Registries'); + + const NON_IDLE_EVENTS = 'mousemove mousedown touchstart touchend touchmove click scroll keypress'.split(/\s+/); + let IDLE_TIMER_SETTER; + + const PosResChrome = (Chrome) => + class extends Chrome { + /** + * @override + */ + async start() { + await super.start(); + if (this.env.pos.config.iface_floorplan) { + this._setActivityListeners(); + } + } + /** + * @override + * Do not set `FloorScreen` to the order. + */ + _setScreenData(name) { + if (name === 'FloorScreen') return; + super._setScreenData(...arguments); + } + /** + * @override + * `FloorScreen` is the start screen if there are floors. + */ + get startScreen() { + if (this.env.pos.config.iface_floorplan) { + const table = this.env.pos.table; + return { name: 'FloorScreen', props: { floor: table ? table.floor : null } }; + } else { + return super.startScreen; + } + } + /** + * @override + * Order is set to null when table is selected. There is no saved + * screen for null order so show `FloorScreen` instead. + */ + _showSavedScreen(pos, newSelectedOrder) { + if (!newSelectedOrder) { + this.showScreen('FloorScreen', { floor: pos.table ? pos.table.floor : null }); + } else { + super._showSavedScreen(pos, newSelectedOrder); + } + } + _setActivityListeners() { + IDLE_TIMER_SETTER = this._setIdleTimer.bind(this); + for (const event of NON_IDLE_EVENTS) { + window.addEventListener(event, IDLE_TIMER_SETTER); + } + } + _setIdleTimer() { + if (this._shouldResetIdleTimer()) { + clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => { + this._actionAfterIdle(); + }, 60000); + } + } + _actionAfterIdle() { + if (this.tempScreen.isShown) { + this.trigger('close-temp-screen'); + } + const table = this.env.pos.table; + this.showScreen('FloorScreen', { floor: table ? table.floor : null }); + } + _shouldResetIdleTimer() { + return this.env.pos.config.iface_floorplan && this.mainScreen.name !== 'FloorScreen'; + } + __showScreen() { + super.__showScreen(...arguments); + this._setIdleTimer(); + } + /** + * @override + * Before closing pos, we remove the event listeners set on window + * for detecting activities outside FloorScreen. + */ + async _closePos() { + if (IDLE_TIMER_SETTER) { + for (const event of NON_IDLE_EVENTS) { + window.removeEventListener(event, IDLE_TIMER_SETTER); + } + } + await super._closePos(); + } + }; + + Registries.Component.extend(Chrome, PosResChrome); + + return Chrome; +}); diff --git a/addons/pos_restaurant/static/src/js/ChromeWidgets/BackToFloorButton.js b/addons/pos_restaurant/static/src/js/ChromeWidgets/BackToFloorButton.js new file mode 100644 index 00000000..c2b62350 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/ChromeWidgets/BackToFloorButton.js @@ -0,0 +1,34 @@ +odoo.define('pos_restaurant.BackToFloorButton', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { posbus } = require('point_of_sale.utils'); + + class BackToFloorButton extends PosComponent { + mounted() { + posbus.on('table-set', this, this.render); + } + willUnmount() { + posbus.on('table-set', this); + } + get table() { + return (this.env.pos && this.env.pos.table) || null; + } + get floor() { + const table = this.table; + return table ? table.floor : null; + } + get hasTable() { + return this.table !== null; + } + backToFloorScreen() { + this.showScreen('FloorScreen', { floor: this.floor }); + } + } + BackToFloorButton.template = 'BackToFloorButton'; + + Registries.Component.add(BackToFloorButton); + + return BackToFloorButton; +}); diff --git a/addons/pos_restaurant/static/src/js/ChromeWidgets/TicketButton.js b/addons/pos_restaurant/static/src/js/ChromeWidgets/TicketButton.js new file mode 100644 index 00000000..1e66bf91 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/ChromeWidgets/TicketButton.js @@ -0,0 +1,33 @@ +odoo.define('pos_restaurant.TicketButton', function (require) { + 'use strict'; + + const TicketButton = require('point_of_sale.TicketButton'); + const Registries = require('point_of_sale.Registries'); + const { posbus } = require('point_of_sale.utils'); + + const PosResTicketButton = (TicketButton) => + class extends TicketButton { + mounted() { + posbus.on('table-set', this, this.render); + } + willUnmount() { + posbus.off('table-set', this); + } + /** + * If no table is set to pos, which means the current main screen + * is floor screen, then the order count should be based on all the orders. + */ + get count() { + if (!this.env.pos || !this.env.pos.config) return 0; + if (this.env.pos.config.iface_floorplan && !this.env.pos.table) { + return this.env.pos.get('orders').models.length; + } else { + return super.count; + } + } + }; + + Registries.Component.extend(TicketButton, PosResTicketButton); + + return TicketButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Resizeable.js b/addons/pos_restaurant/static/src/js/Resizeable.js new file mode 100644 index 00000000..c651ae09 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Resizeable.js @@ -0,0 +1,330 @@ +odoo.define('pos_restaurant.Resizeable', 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'); + + class Resizeable extends PosComponent { + constructor() { + super(...arguments); + + useExternalListener(document, 'mousemove', this.resizeN); + useExternalListener(document, 'mouseup', this.endResizeN); + useListener('mousedown', '.resize-handle-n', this.startResizeN); + + useExternalListener(document, 'mousemove', this.resizeS); + useExternalListener(document, 'mouseup', this.endResizeS); + useListener('mousedown', '.resize-handle-s', this.startResizeS); + + useExternalListener(document, 'mousemove', this.resizeW); + useExternalListener(document, 'mouseup', this.endResizeW); + useListener('mousedown', '.resize-handle-w', this.startResizeW); + + useExternalListener(document, 'mousemove', this.resizeE); + useExternalListener(document, 'mouseup', this.endResizeE); + useListener('mousedown', '.resize-handle-e', this.startResizeE); + + useExternalListener(document, 'mousemove', this.resizeNW); + useExternalListener(document, 'mouseup', this.endResizeNW); + useListener('mousedown', '.resize-handle-nw', this.startResizeNW); + + useExternalListener(document, 'mousemove', this.resizeNE); + useExternalListener(document, 'mouseup', this.endResizeNE); + useListener('mousedown', '.resize-handle-ne', this.startResizeNE); + + useExternalListener(document, 'mousemove', this.resizeSW); + useExternalListener(document, 'mouseup', this.endResizeSW); + useListener('mousedown', '.resize-handle-sw', this.startResizeSW); + + useExternalListener(document, 'mousemove', this.resizeSE); + useExternalListener(document, 'mouseup', this.endResizeSE); + useListener('mousedown', '.resize-handle-se', this.startResizeSE); + + useExternalListener(document, 'touchmove', this.resizeN); + useExternalListener(document, 'touchend', this.endResizeN); + useListener('touchstart', '.resize-handle-n', this.startResizeN); + + useExternalListener(document, 'touchmove', this.resizeS); + useExternalListener(document, 'touchend', this.endResizeS); + useListener('touchstart', '.resize-handle-s', this.startResizeS); + + useExternalListener(document, 'touchmove', this.resizeW); + useExternalListener(document, 'touchend', this.endResizeW); + useListener('touchstart', '.resize-handle-w', this.startResizeW); + + useExternalListener(document, 'touchmove', this.resizeE); + useExternalListener(document, 'touchend', this.endResizeE); + useListener('touchstart', '.resize-handle-e', this.startResizeE); + + useExternalListener(document, 'touchmove', this.resizeNW); + useExternalListener(document, 'touchend', this.endResizeNW); + useListener('touchstart', '.resize-handle-nw', this.startResizeNW); + + useExternalListener(document, 'touchmove', this.resizeNE); + useExternalListener(document, 'touchend', this.endResizeNE); + useListener('touchstart', '.resize-handle-ne', this.startResizeNE); + + useExternalListener(document, 'touchmove', this.resizeSW); + useExternalListener(document, 'touchend', this.endResizeSW); + useListener('touchstart', '.resize-handle-sw', this.startResizeSW); + + useExternalListener(document, 'touchmove', this.resizeSE); + useExternalListener(document, 'touchend', this.endResizeSE); + useListener('touchstart', '.resize-handle-se', this.startResizeSE); + + this.size = { height: 0, width: 0 }; + this.loc = { top: 0, left: 0 }; + this.tempSize = {}; + } + 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; + } + startResizeN(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { y } = this._getEventLoc(realEvent); + this.isResizingN = true; + this.startY = y; + this.size.height = this.el.offsetHeight; + this.loc.top = this.el.offsetTop; + event.stopPropagation(); + } + resizeN(event) { + if (this.isResizingN) { + const { y: newY } = this._getEventLoc(event); + let dY = newY - this.startY; + if (dY < 0 && Math.abs(dY) > this.loc.top) { + dY = -this.loc.top; + } else if (dY > 0 && dY > this.size.height) { + dY = this.size.height; + } + this.el.style.height = `${this.size.height - dY}px`; + this.el.style.top = `${this.loc.top + dY}px`; + } + } + endResizeN() { + if (this.isResizingN && !this.isResizingE && !this.isResizingW && !this.isResizingS) { + this.isResizingN = false; + this._triggerResizeEnd(); + } + } + startResizeS(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { y } = this._getEventLoc(realEvent); + this.isResizingS = true; + this.startY = y; + this.size.height = this.el.offsetHeight; + this.loc.top = this.el.offsetTop; + event.stopPropagation(); + } + resizeS(event) { + if (this.isResizingS) { + const { y: newY } = this._getEventLoc(event); + let dY = newY - this.startY; + if (dY > 0 && dY > this.limitAreaHeight - (this.size.height + this.loc.top)) { + dY = this.limitAreaHeight - (this.size.height + this.loc.top); + } else if (dY < 0 && Math.abs(dY) > this.size.height) { + dY = -this.size.height; + } + this.el.style.height = `${this.size.height + dY}px`; + } + } + endResizeS() { + if (!this.isResizingN && !this.isResizingE && !this.isResizingW && this.isResizingS) { + this.isResizingS = false; + this._triggerResizeEnd(); + } + } + startResizeW(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { x } = this._getEventLoc(realEvent); + this.isResizingW = true; + this.startX = x; + this.size.width = this.el.offsetWidth; + this.loc.left = this.el.offsetLeft; + event.stopPropagation(); + } + resizeW(event) { + if (this.isResizingW) { + const { x: newX } = this._getEventLoc(event); + let dX = newX - this.startX; + if (dX > 0 && dX > this.size.width) { + dX = this.size.width; + } else if (dX < 0 && Math.abs(dX) > this.loc.left + Math.abs(this.limitLeft)) { + dX = -this.loc.left + this.limitLeft; + } + this.el.style.width = `${this.size.width - dX}px`; + this.el.style.left = `${this.loc.left + dX}px`; + } + } + endResizeW() { + if (!this.isResizingN && !this.isResizingE && this.isResizingW && !this.isResizingS) { + this.isResizingW = false; + this._triggerResizeEnd(); + } + } + startResizeE(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { x } = this._getEventLoc(realEvent); + this.isResizingE = true; + this.startX = x; + this.size.width = this.el.offsetWidth; + this.loc.left = this.el.offsetLeft; + event.stopPropagation(); + } + resizeE(event) { + if (this.isResizingE) { + const { x: newX } = this._getEventLoc(event); + let dX = newX - this.startX; + if ( + dX > 0 && + dX > + this.limitAreaWidth - + (this.size.width + this.loc.left + Math.abs(this.limitLeft)) + ) { + dX = + this.limitAreaWidth - + (this.size.width + this.loc.left + Math.abs(this.limitLeft)); + } else if (dX < 0 && Math.abs(dX) > this.size.width) { + dX = -this.size.width; + } + this.el.style.width = `${this.size.width + dX}px`; + } + } + endResizeE() { + if (!this.isResizingN && this.isResizingE && !this.isResizingW && !this.isResizingS) { + this.isResizingE = false; + this._triggerResizeEnd(); + } + } + startResizeNW(event) { + this.startResizeN(event); + this.startResizeW(event); + } + resizeNW(event) { + this.resizeN(event); + this.resizeW(event); + } + endResizeNW() { + if (this.isResizingN && !this.isResizingE && this.isResizingW && !this.isResizingS) { + this.isResizingN = false; + this.isResizingW = false; + this._triggerResizeEnd(); + } + } + startResizeNE(event) { + this.startResizeN(event); + this.startResizeE(event); + } + resizeNE(event) { + this.resizeN(event); + this.resizeE(event); + } + endResizeNE() { + if (this.isResizingN && this.isResizingE && !this.isResizingW && !this.isResizingS) { + this.isResizingN = false; + this.isResizingE = false; + this._triggerResizeEnd(); + } + } + startResizeSE(event) { + this.startResizeS(event); + this.startResizeE(event); + } + resizeSE(event) { + this.resizeS(event); + this.resizeE(event); + } + endResizeSE() { + if (!this.isResizingN && this.isResizingE && !this.isResizingW && this.isResizingS) { + this.isResizingS = false; + this.isResizingE = false; + this._triggerResizeEnd(); + } + } + startResizeSW(event) { + this.startResizeS(event); + this.startResizeW(event); + } + resizeSW(event) { + this.resizeS(event); + this.resizeW(event); + } + endResizeSW() { + if (!this.isResizingN && !this.isResizingE && this.isResizingW && this.isResizingS) { + this.isResizingS = false; + this.isResizingW = false; + this._triggerResizeEnd(); + } + } + _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, + }; + } + _triggerResizeEnd() { + const size = { + height: this.el.offsetHeight, + width: this.el.offsetWidth, + }; + const loc = { + top: this.el.offsetTop, + left: this.el.offsetLeft, + }; + this.trigger('resize-end', { size, loc }); + } + } + Resizeable.template = 'Resizeable'; + + Registries.Component.add(Resizeable); + + return Resizeable; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/BillScreen.js b/addons/pos_restaurant/static/src/js/Screens/BillScreen.js new file mode 100644 index 00000000..8ecd7805 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/BillScreen.js @@ -0,0 +1,31 @@ +odoo.define('pos_restaurant.BillScreen', function (require) { + 'use strict'; + + const ReceiptScreen = require('point_of_sale.ReceiptScreen'); + const Registries = require('point_of_sale.Registries'); + + const BillScreen = (ReceiptScreen) => { + class BillScreen extends ReceiptScreen { + confirm() { + this.props.resolve({ confirmed: true, payload: null }); + this.trigger('close-temp-screen'); + } + whenClosing() { + this.confirm(); + } + /** + * @override + */ + async printReceipt() { + await super.printReceipt(); + this.currentOrder._printed = false; + } + } + BillScreen.template = 'BillScreen'; + return BillScreen; + }; + + Registries.Component.addByExtending(BillScreen, ReceiptScreen); + + return BillScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/FloorScreen/EditBar.js b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/EditBar.js new file mode 100644 index 00000000..43b16d93 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/EditBar.js @@ -0,0 +1,19 @@ +odoo.define('pos_restaurant.EditBar', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { useState } = owl.hooks; + + class EditBar extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ isColorPicker: false }) + } + } + EditBar.template = 'EditBar'; + + Registries.Component.add(EditBar); + + return EditBar; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/FloorScreen/EditableTable.js b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/EditableTable.js new file mode 100644 index 00000000..4aeb781d --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/EditableTable.js @@ -0,0 +1,60 @@ +odoo.define('pos_restaurant.EditableTable', function(require) { + 'use strict'; + + const { onPatched, onMounted } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class EditableTable extends PosComponent { + constructor() { + super(...arguments); + useListener('resize-end', this._onResizeEnd); + useListener('drag-end', this._onDragEnd); + onPatched(this._setElementStyle.bind(this)); + onMounted(this._setElementStyle.bind(this)); + } + _setElementStyle() { + const table = this.props.table; + function unit(val) { + return `${val}px`; + } + const style = { + width: unit(table.width), + height: unit(table.height), + 'line-height': unit(table.height), + top: unit(table.position_v), + left: unit(table.position_h), + 'border-radius': table.shape === 'round' ? unit(1000) : '3px', + }; + if (table.color) { + style.background = table.color; + } + if (table.height >= 150 && table.width >= 150) { + style['font-size'] = '32px'; + } + Object.assign(this.el.style, style); + } + _onResizeEnd(event) { + const { size, loc } = event.detail; + const table = this.props.table; + table.width = size.width; + table.height = size.height; + table.position_v = loc.top; + table.position_h = loc.left; + this.trigger('save-table', this.props.table); + } + _onDragEnd(event) { + const { loc } = event.detail; + const table = this.props.table; + table.position_v = loc.top; + table.position_h = loc.left; + this.trigger('save-table', this.props.table); + } + } + EditableTable.template = 'EditableTable'; + + Registries.Component.add(EditableTable); + + return EditableTable; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js new file mode 100644 index 00000000..7c46f22b --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js @@ -0,0 +1,315 @@ +odoo.define('pos_restaurant.FloorScreen', function (require) { + 'use strict'; + + const { debounce } = owl.utils; + const PosComponent = require('point_of_sale.PosComponent'); + const { useState, useRef } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class FloorScreen extends PosComponent { + /** + * @param {Object} props + * @param {Object} props.floor + */ + constructor() { + super(...arguments); + this._setTableColor = debounce(this._setTableColor, 70); + this._setFloorColor = debounce(this._setFloorColor, 70); + useListener('select-table', this._onSelectTable); + useListener('deselect-table', this._onDeselectTable); + useListener('save-table', this._onSaveTable); + useListener('create-table', this._createTable); + useListener('duplicate-table', this._duplicateTable); + useListener('rename-table', this._renameTable); + useListener('change-seats-num', this._changeSeatsNum); + useListener('change-shape', this._changeShape); + useListener('set-table-color', this._setTableColor); + useListener('set-floor-color', this._setFloorColor); + useListener('delete-table', this._deleteTable); + const floor = this.props.floor ? this.props.floor : this.env.pos.floors[0]; + this.state = useState({ + selectedFloorId: floor.id, + selectedTableId: null, + isEditMode: false, + floorBackground: floor.background_color, + floorMapScrollTop: 0, + }); + this.floorMapRef = useRef('floor-map-ref'); + } + patched() { + this.floorMapRef.el.style.background = this.state.floorBackground; + this.state.floorMapScrollTop = this.floorMapRef.el.getBoundingClientRect().top; + } + mounted() { + if (this.env.pos.table) { + this.env.pos.set_table(null); + } + this.floorMapRef.el.style.background = this.state.floorBackground; + this.state.floorMapScrollTop = this.floorMapRef.el.getBoundingClientRect().top; + // call _tableLongpolling once then set interval of 5sec. + this._tableLongpolling(); + this.tableLongpolling = setInterval(this._tableLongpolling.bind(this), 5000); + } + willUnmount() { + clearInterval(this.tableLongpolling); + } + get activeFloor() { + return this.env.pos.floors_by_id[this.state.selectedFloorId]; + } + get activeTables() { + return this.activeFloor.tables; + } + get isFloorEmpty() { + return this.activeTables.length === 0; + } + get selectedTable() { + return this.state.selectedTableId !== null + ? this.env.pos.tables_by_id[this.state.selectedTableId] + : false; + } + selectFloor(floor) { + this.state.selectedFloorId = floor.id; + this.state.floorBackground = this.activeFloor.background_color; + this.state.isEditMode = false; + this.state.selectedTableId = null; + } + toggleEditMode() { + this.state.isEditMode = !this.state.isEditMode; + this.state.selectedTableId = null; + } + async _createTable() { + const newTable = await this._createTableHelper(); + if (newTable) { + this.state.selectedTableId = newTable.id; + } + } + async _duplicateTable() { + if (!this.selectedTable) return; + const newTable = await this._createTableHelper(this.selectedTable); + if (newTable) { + this.state.selectedTableId = newTable.id; + } + } + async _changeSeatsNum() { + const selectedTable = this.selectedTable + if (!selectedTable) return; + const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', { + startingValue: selectedTable.seats, + cheap: true, + title: this.env._t('Number of Seats ?'), + }); + if (!confirmed) return; + const newSeatsNum = parseInt(inputNumber, 10) || selectedTable.seats; + if (newSeatsNum !== selectedTable.seats) { + selectedTable.seats = newSeatsNum; + await this._save(selectedTable); + } + } + async _changeShape() { + if (!this.selectedTable) return; + this.selectedTable.shape = this.selectedTable.shape === 'square' ? 'round' : 'square'; + this.render(); + await this._save(this.selectedTable); + } + async _renameTable() { + const selectedTable = this.selectedTable; + if (!selectedTable) return; + const { confirmed, payload: newName } = await this.showPopup('TextInputPopup', { + startingValue: selectedTable.name, + title: this.env._t('Table Name ?'), + }); + if (!confirmed) return; + if (newName !== selectedTable.name) { + selectedTable.name = newName; + await this._save(selectedTable); + } + } + async _setTableColor({ detail: color }) { + this.selectedTable.color = color; + this.render(); + await this._save(this.selectedTable); + } + async _setFloorColor({ detail: color }) { + this.state.floorBackground = color; + this.activeFloor.background_color = color; + try { + await this.rpc({ + model: 'restaurant.floor', + method: 'write', + args: [[this.activeFloor.id], { background_color: color }], + }); + } catch (error) { + if (error.message.code < 0) { + await this.showPopup('OfflineErrorPopup', { + title: this.env._t('Offline'), + body: this.env._t('Unable to change background color'), + }); + } else { + throw error; + } + } + } + async _deleteTable() { + if (!this.selectedTable) return; + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Are you sure ?'), + body: this.env._t('Removing a table cannot be undone'), + }); + if (!confirmed) return; + try { + const originalSelectedTableId = this.state.selectedTableId; + await this.rpc({ + model: 'restaurant.table', + method: 'create_from_ui', + args: [{ active: false, id: originalSelectedTableId }], + }); + this.activeFloor.tables = this.activeTables.filter( + (table) => table.id !== originalSelectedTableId + ); + // Value of an object can change inside async function call. + // Which means that in this code block, the value of `state.selectedTableId` + // before the await call can be different after the finishing the await call. + // Since we wanted to disable the selected table after deletion, we should be + // setting the selectedTableId to null. However, we only do this if nothing + // else is selected during the rpc call. + if (this.state.selectedTableId === originalSelectedTableId) { + this.state.selectedTableId = null; + } + } catch (error) { + if (error.message.code < 0) { + await this.showPopup('OfflineErrorPopup', { + title: this.env._t('Offline'), + body: this.env._t('Unable to delete table'), + }); + } else { + throw error; + } + } + } + _onSelectTable(event) { + const table = event.detail; + if (this.state.isEditMode) { + this.state.selectedTableId = table.id; + } else { + this.env.pos.set_table(table); + } + } + _onDeselectTable() { + this.state.selectedTableId = null; + } + async _createTableHelper(copyTable) { + let newTable; + if (copyTable) { + newTable = Object.assign({}, copyTable); + newTable.position_h += 10; + newTable.position_v += 10; + } else { + newTable = { + position_v: 100, + position_h: 100, + width: 75, + height: 75, + shape: 'square', + seats: 1, + }; + } + newTable.name = this._getNewTableName(newTable.name); + delete newTable.id; + newTable.floor_id = [this.activeFloor.id, '']; + newTable.floor = this.activeFloor; + try { + await this._save(newTable); + this.activeTables.push(newTable); + return newTable; + } catch (error) { + if (error.message.code < 0) { + await this.showPopup('ErrorPopup', { + title: this.env._t('Offline'), + body: this.env._t('Unable to create table because you are offline.'), + }); + return; + } else { + throw error; + } + } + } + _getNewTableName(name) { + if (name) { + const num = Number((name.match(/\d+/g) || [])[0] || 0); + const str = name.replace(/\d+/g, ''); + const n = { num: num, str: str }; + n.num += 1; + this._lastName = n; + } else if (this._lastName) { + this._lastName.num += 1; + } else { + this._lastName = { num: 1, str: 'T' }; + } + return '' + this._lastName.str + this._lastName.num; + } + async _save(table) { + const fields = this.env.pos.models.find((model) => model.model === 'restaurant.table') + .fields; + const serializeTable = {}; + for (let field of fields) { + if (typeof table[field] !== 'undefined') { + serializeTable[field] = table[field]; + } + } + serializeTable.id = table.id; + const tableId = await this.rpc({ + model: 'restaurant.table', + method: 'create_from_ui', + args: [serializeTable], + }); + table.id = tableId; + this.env.pos.tables_by_id[tableId] = table; + } + async _onSaveTable(event) { + const table = event.detail; + await this._save(table); + } + async _tableLongpolling() { + if (this.state.isEditMode) { + return; + } + try { + const result = await this.rpc({ + model: 'pos.config', + method: 'get_tables_order_count', + args: [this.env.pos.config.id], + }); + result.forEach((table) => { + const table_obj = this.env.pos.tables_by_id[table.id]; + const unsynced_orders = this.env.pos + .get_table_orders(table_obj) + .filter( + (o) => + o.server_id === undefined && + (o.orderlines.length !== 0 || o.paymentlines.length !== 0) && + // do not count the orders that are already finalized + !o.finalized + ).length; + table_obj.order_count = table.orders + unsynced_orders; + }); + this.render(); + } catch (error) { + if (error.message.code < 0) { + await this.showPopup('OfflineErrorPopup', { + title: 'Offline', + body: 'Unable to get orders count', + }); + } else { + throw error; + } + } + } + } + FloorScreen.template = 'FloorScreen'; + FloorScreen.hideOrderSelector = true; + + Registries.Component.add(FloorScreen); + + return FloorScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/FloorScreen/TableWidget.js b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/TableWidget.js new file mode 100644 index 00000000..48dfdad7 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/TableWidget.js @@ -0,0 +1,73 @@ +odoo.define('pos_restaurant.TableWidget', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class TableWidget extends PosComponent { + mounted() { + const table = this.props.table; + function unit(val) { + return `${val}px`; + } + const style = { + width: unit(table.width), + height: unit(table.height), + 'line-height': unit(table.height), + top: unit(table.position_v), + left: unit(table.position_h), + 'border-radius': table.shape === 'round' ? unit(1000) : '3px', + }; + if (table.color) { + style.background = table.color; + } + if (table.height >= 150 && table.width >= 150) { + style['font-size'] = '32px'; + } + Object.assign(this.el.style, style); + + const tableCover = this.el.querySelector('.table-cover'); + Object.assign(tableCover.style, { height: `${Math.ceil(this.fill * 100)}%` }); + } + get fill() { + const customerCount = this.env.pos.get_customer_count(this.props.table); + return Math.min(1, Math.max(0, customerCount / this.props.table.seats)); + } + get orderCount() { + const table = this.props.table; + return table.order_count !== undefined + ? table.order_count + : this.env.pos + .get_table_orders(table) + .filter(o => o.orderlines.length !== 0 || o.paymentlines.length !== 0).length; + } + get orderCountClass() { + const notifications = this._getNotifications(); + return { + 'order-count': true, + 'notify-printing': notifications.printing, + 'notify-skipped': notifications.skipped, + }; + } + _getNotifications() { + const orders = this.env.pos.get_table_orders(this.props.table); + + let hasChangesCount = 0; + let hasSkippedCount = 0; + for (let i = 0; i < orders.length; i++) { + if (orders[i].hasChangesToPrint()) { + hasChangesCount++; + } else if (orders[i].hasSkippedChanges()) { + hasSkippedCount++; + } + } + + return hasChangesCount ? { printing: true } : hasSkippedCount ? { skipped: true } : {}; + } + } + TableWidget.template = 'TableWidget'; + + Registries.Component.add(TableWidget); + + return TableWidget; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js new file mode 100644 index 00000000..8753be25 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js @@ -0,0 +1,28 @@ +odoo.define('pos_restaurant.OrderManagementScreen', function (require) { + 'use strict'; + + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const Registries = require('point_of_sale.Registries'); + + const PosResOrderManagementScreen = (OrderManagementScreen) => + class extends OrderManagementScreen { + /** + * @override + */ + _setOrder(order) { + if (this.env.pos.config.module_pos_restaurant) { + const currentOrder = this.env.pos.get_order(); + this.env.pos.set_table(order.table, order); + if (currentOrder && currentOrder.uid === order.uid) { + this.close(); + } + } else { + super._setOrder(order); + } + } + }; + + Registries.Component.extend(OrderManagementScreen, PosResOrderManagementScreen); + + return OrderManagementScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderRow.js b/addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderRow.js new file mode 100644 index 00000000..f5a32114 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderRow.js @@ -0,0 +1,17 @@ +odoo.define('pos_restaurant.OrderRow', function (require) { + 'use strict'; + + const OrderRow = require('point_of_sale.OrderRow'); + const Registries = require('point_of_sale.Registries'); + + const PosResOrderRow = (OrderRow) => + class extends OrderRow { + get table() { + return this.order.table ? this.order.table.name : ''; + } + }; + + Registries.Component.extend(OrderRow, PosResOrderRow); + + return OrderRow; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js b/addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js new file mode 100644 index 00000000..472f7cad --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js @@ -0,0 +1,48 @@ +odoo.define('pos_restaurant.PosResPaymentScreen', function (require) { + 'use strict'; + + const PaymentScreen = require('point_of_sale.PaymentScreen'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + const PosResPaymentScreen = (PaymentScreen) => + class extends PaymentScreen { + constructor() { + super(...arguments); + useListener('send-payment-adjust', this._sendPaymentAdjust); + } + + async _sendPaymentAdjust({ detail: line }) { + const previous_amount = line.get_amount(); + const amount_diff = line.order.get_total_with_tax() - line.order.get_total_paid(); + line.set_amount(previous_amount + amount_diff); + line.set_payment_status('waiting'); + + const payment_terminal = line.payment_method.payment_terminal; + const isAdjustSuccessful = await payment_terminal.send_payment_adjust(line.cid); + if (isAdjustSuccessful) { + line.set_payment_status('done'); + } else { + line.set_amount(previous_amount); + line.set_payment_status('done'); + } + } + + get nextScreen() { + const order = this.currentOrder; + if (!this.env.pos.config.set_tip_after_payment || order.is_tipped) { + return super.nextScreen; + } + // Take the first payment method as the main payment. + const mainPayment = order.get_paymentlines()[0]; + if (mainPayment.canBeAdjusted()) { + return 'TipScreen'; + } + return super.nextScreen; + } + }; + + Registries.Component.extend(PaymentScreen, PosResPaymentScreen); + + return PosResPaymentScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/OrderlineNoteButton.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/OrderlineNoteButton.js new file mode 100644 index 00000000..58681810 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/OrderlineNoteButton.js @@ -0,0 +1,42 @@ +odoo.define('pos_restaurant.OrderlineNoteButton', 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 OrderlineNoteButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + get selectedOrderline() { + return this.env.pos.get_order().get_selected_orderline(); + } + async onClick() { + if (!this.selectedOrderline) return; + + const { confirmed, payload: inputNote } = await this.showPopup('TextAreaPopup', { + startingValue: this.selectedOrderline.get_note(), + title: this.env._t('Add Note'), + }); + + if (confirmed) { + this.selectedOrderline.set_note(inputNote); + } + } + } + OrderlineNoteButton.template = 'OrderlineNoteButton'; + + ProductScreen.addControlButton({ + component: OrderlineNoteButton, + condition: function() { + return this.env.pos.config.module_pos_restaurant; + }, + }); + + Registries.Component.add(OrderlineNoteButton); + + return OrderlineNoteButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/PrintBillButton.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/PrintBillButton.js new file mode 100644 index 00000000..7563d9fc --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/PrintBillButton.js @@ -0,0 +1,38 @@ +odoo.define('pos_restaurant.PrintBillButton', 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 PrintBillButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + async onClick() { + const order = this.env.pos.get_order(); + if (order.get_orderlines().length > 0) { + await this.showTempScreen('BillScreen'); + } else { + await this.showPopup('ErrorPopup', { + title: this.env._t('Nothing to Print'), + body: this.env._t('There are no order lines'), + }); + } + } + } + PrintBillButton.template = 'PrintBillButton'; + + ProductScreen.addControlButton({ + component: PrintBillButton, + condition: function() { + return this.env.pos.config.iface_printbill; + }, + }); + + Registries.Component.add(PrintBillButton); + + return PrintBillButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/SplitBillButton.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/SplitBillButton.js new file mode 100644 index 00000000..58ace925 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/SplitBillButton.js @@ -0,0 +1,33 @@ +odoo.define('pos_restaurant.SplitBillButton', 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 SplitBillButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + async onClick() { + const order = this.env.pos.get_order(); + if (order.get_orderlines().length > 0) { + this.showScreen('SplitBillScreen'); + } + } + } + SplitBillButton.template = 'SplitBillButton'; + + ProductScreen.addControlButton({ + component: SplitBillButton, + condition: function() { + return this.env.pos.config.iface_splitbill; + }, + }); + + Registries.Component.add(SplitBillButton); + + return SplitBillButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/SubmitOrderButton.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/SubmitOrderButton.js new file mode 100644 index 00000000..948410e5 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/SubmitOrderButton.js @@ -0,0 +1,70 @@ +odoo.define('pos_restaurant.SubmitOrderButton', 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'); + + /** + * IMPROVEMENT: Perhaps this class is quite complicated for its worth. + * This is because it needs to listen to changes to the current order. + * Also, the current order changes when the selectedOrder in pos is changed. + * After setting new current order, we update the listeners. + */ + class SubmitOrderButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + this._currentOrder = this.env.pos.get_order(); + this._currentOrder.orderlines.on('change', this.render, this); + this.env.pos.on('change:selectedOrder', this._updateCurrentOrder, this); + } + willUnmount() { + this._currentOrder.orderlines.off('change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + async onClick() { + const order = this.env.pos.get_order(); + if (order.hasChangesToPrint()) { + const isPrintSuccessful = await order.printChanges(); + if (isPrintSuccessful) { + order.saveChanges(); + } else { + await this.showPopup('ErrorPopup', { + title: 'Printing failed', + body: 'Failed in printing the changes in the order', + }); + } + } + } + get addedClasses() { + if (!this._currentOrder) return {}; + const changes = this._currentOrder.hasChangesToPrint(); + const skipped = changes ? false : this._currentOrder.hasSkippedChanges(); + return { + highlight: changes, + altlight: skipped, + }; + } + _updateCurrentOrder(pos, newSelectedOrder) { + this._currentOrder.orderlines.off('change', null, this); + if (newSelectedOrder) { + this._currentOrder = newSelectedOrder; + this._currentOrder.orderlines.on('change', this.render, this); + } + } + } + SubmitOrderButton.template = 'SubmitOrderButton'; + + ProductScreen.addControlButton({ + component: SubmitOrderButton, + condition: function() { + return this.env.pos.printers.length; + }, + }); + + Registries.Component.add(SubmitOrderButton); + + return SubmitOrderButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/TableGuestsButton.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/TableGuestsButton.js new file mode 100644 index 00000000..e015496d --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/TableGuestsButton.js @@ -0,0 +1,44 @@ +odoo.define('pos_restaurant.TableGuestsButton', 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 TableGuestsButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get nGuests() { + return this.currentOrder ? this.currentOrder.get_customer_count() : 0; + } + async onClick() { + const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', { + startingValue: this.nGuests, + cheap: true, + title: this.env._t('Guests ?'), + }); + + if (confirmed) { + this.env.pos.get_order().set_customer_count(parseInt(inputNumber, 10) || 1); + } + } + } + TableGuestsButton.template = 'TableGuestsButton'; + + ProductScreen.addControlButton({ + component: TableGuestsButton, + condition: function() { + return this.env.pos.config.module_pos_restaurant; + }, + }); + + Registries.Component.add(TableGuestsButton); + + return TableGuestsButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/TransferOrderButton.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/TransferOrderButton.js new file mode 100644 index 00000000..ed72e2a7 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/ControlButtons/TransferOrderButton.js @@ -0,0 +1,30 @@ +odoo.define('pos_restaurant.TransferOrderButton', 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 TransferOrderButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + onClick() { + this.env.pos.transfer_order_to_different_table(); + } + } + TransferOrderButton.template = 'TransferOrderButton'; + + ProductScreen.addControlButton({ + component: TransferOrderButton, + condition: function() { + return this.env.pos.config.iface_floorplan; + }, + }); + + Registries.Component.add(TransferOrderButton); + + return TransferOrderButton; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ProductScreen/Orderline.js b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/Orderline.js new file mode 100644 index 00000000..2204405c --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ProductScreen/Orderline.js @@ -0,0 +1,46 @@ +odoo.define('pos_restaurant.Orderline', function(require) { + 'use strict'; + + const Orderline = require('point_of_sale.Orderline'); + const Registries = require('point_of_sale.Registries'); + + const PosResOrderline = Orderline => + class extends Orderline { + /** + * @override + */ + get addedClasses() { + const res = super.addedClasses; + Object.assign(res, { + dirty: this.props.line.mp_dirty, + skip: this.props.line.mp_skip, + }); + return res; + } + /** + * @override + * if doubleclick, change mp_dirty to mp_skip + * + * IMPROVEMENT: Instead of handling both double click and click in single + * method, perhaps we can separate double click from single click. + */ + selectLine() { + const line = this.props.line; // the orderline + if (this.env.pos.get_order().selected_orderline !== line) { + this.mp_dbclk_time = new Date().getTime(); + } else if (!this.mp_dbclk_time) { + this.mp_dbclk_time = new Date().getTime(); + } else if (this.mp_dbclk_time + 500 > new Date().getTime()) { + line.set_skip(!line.mp_skip); + this.mp_dbclk_time = 0; + } else { + this.mp_dbclk_time = new Date().getTime(); + } + super.selectLine(); + } + }; + + Registries.Component.extend(Orderline, PosResOrderline); + + return Orderline; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js new file mode 100644 index 00000000..689c3bf4 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -0,0 +1,28 @@ +odoo.define('pos_restaurant.ReceiptScreen', function(require) { + 'use strict'; + + const ReceiptScreen = require('point_of_sale.ReceiptScreen'); + const Registries = require('point_of_sale.Registries'); + + const PosResReceiptScreen = ReceiptScreen => + class extends ReceiptScreen { + /** + * @override + */ + get nextScreen() { + if ( + this.env.pos.config.module_pos_restaurant && + this.env.pos.config.iface_floorplan + ) { + const table = this.env.pos.table; + return { name: 'FloorScreen', props: { floor: table ? table.floor : null } }; + } else { + return super.nextScreen; + } + } + }; + + Registries.Component.extend(ReceiptScreen, PosResReceiptScreen); + + return ReceiptScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/SplitBillScreen/SplitBillScreen.js b/addons/pos_restaurant/static/src/js/Screens/SplitBillScreen/SplitBillScreen.js new file mode 100644 index 00000000..f44af5bf --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/SplitBillScreen/SplitBillScreen.js @@ -0,0 +1,197 @@ +odoo.define('pos_restaurant.SplitBillScreen', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const { useState } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const models = require('point_of_sale.models'); + const Registries = require('point_of_sale.Registries'); + + class SplitBillScreen extends PosComponent { + constructor() { + super(...arguments); + useListener('click-line', this.onClickLine); + this.splitlines = useState(this._initSplitLines(this.env.pos.get_order())); + this.newOrderLines = {}; + this.newOrder = new models.Order( + {}, + { + pos: this.env.pos, + temporary: true, + } + ); + this._isFinal = false; + } + mounted() { + this.env.pos.on('change:selectedOrder', this._resetState, this); + } + willUnmount() { + this.env.pos.off('change:selectedOrder', null, this); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get orderlines() { + return this.currentOrder.get_orderlines(); + } + onClickLine(event) { + const line = event.detail; + this._splitQuantity(line); + this._updateNewOrder(line); + } + back() { + this.showScreen('ProductScreen'); + } + proceed() { + if (_.isEmpty(this.splitlines)) + // Splitlines is empty + return; + + this._isFinal = true; + delete this.newOrder.temporary; + + if (this._isFullPayOrder()) { + this.showScreen('PaymentScreen'); + } else { + this._setQuantityOnCurrentOrder(); + + this.newOrder.set_screen_data({ name: 'PaymentScreen' }); + + // for the kitchen printer we assume that everything + // has already been sent to the kitchen before splitting + // the bill. So we save all changes both for the old + // order and for the new one. This is not entirely correct + // but avoids flooding the kitchen with unnecessary orders. + // Not sure what to do in this case. + + if (this.newOrder.saveChanges) { + this.currentOrder.saveChanges(); + this.newOrder.saveChanges(); + } + + this.newOrder.set_customer_count(1); + const newCustomerCount = this.currentOrder.get_customer_count() - 1; + this.currentOrder.set_customer_count(newCustomerCount || 1); + this.currentOrder.set_screen_data({ name: 'ProductScreen' }); + + this.env.pos.get('orders').add(this.newOrder); + this.env.pos.set('selectedOrder', this.newOrder); + } + } + /** + * @param {models.Order} order + * @returns {Object<{ quantity: number }>} splitlines + */ + _initSplitLines(order) { + const splitlines = {}; + for (let line of order.get_orderlines()) { + splitlines[line.id] = { product: line.get_product().id, quantity: 0 }; + } + return splitlines; + } + _splitQuantity(line) { + const split = this.splitlines[line.id]; + + let totalQuantity = 0; + + this.env.pos.get_order().get_orderlines().forEach(function(orderLine) { + if(orderLine.get_product().id === split.product) + totalQuantity += orderLine.get_quantity(); + }); + + if(line.get_quantity() > 0) { + if (!line.get_unit().is_pos_groupable) { + if (split.quantity !== line.get_quantity()) { + split.quantity = line.get_quantity(); + } else { + split.quantity = 0; + } + } else { + if (split.quantity < totalQuantity) { + split.quantity += line.get_unit().is_pos_groupable? 1: line.get_unit().rounding; + if (split.quantity > line.get_quantity()) { + split.quantity = line.get_quantity(); + } + } else { + split.quantity = 0; + } + } + } + } + _updateNewOrder(line) { + const split = this.splitlines[line.id]; + let orderline = this.newOrderLines[line.id]; + if (split.quantity) { + if (!orderline) { + orderline = line.clone(); + this.newOrder.add_orderline(orderline); + this.newOrderLines[line.id] = orderline; + } + orderline.set_quantity(split.quantity, 'do not recompute unit price'); + } else if (orderline) { + this.newOrder.remove_orderline(orderline); + this.newOrderLines[line.id] = null; + } + } + _isFullPayOrder() { + let order = this.env.pos.get_order(); + let full = true; + let splitlines = this.splitlines; + let groupedLines = _.groupBy(order.get_orderlines(), line => line.get_product().id); + + Object.keys(groupedLines).forEach(function (lineId) { + var maxQuantity = groupedLines[lineId].reduce(((quantity, line) => quantity + line.get_quantity()), 0); + Object.keys(splitlines).forEach(id => { + let split = splitlines[id]; + if(split.product === groupedLines[lineId][0].get_product().id) + maxQuantity -= split.quantity; + }); + if(maxQuantity !== 0) + full = false; + }); + + return full; + } + _setQuantityOnCurrentOrder() { + let order = this.env.pos.get_order(); + for (var id in this.splitlines) { + var split = this.splitlines[id]; + var line = this.currentOrder.get_orderline(parseInt(id)); + + if(!this.props.disallow) { + line.set_quantity( + line.get_quantity() - split.quantity, + 'do not recompute unit price' + ); + if (Math.abs(line.get_quantity()) < 0.00001) { + this.currentOrder.remove_orderline(line); + } + } else { + if(split.quantity) { + let decreaseLine = line.clone(); + decreaseLine.order = order; + decreaseLine.noDecrease = true; + decreaseLine.set_quantity(-split.quantity); + order.add_orderline(decreaseLine); + } + } + } + } + _resetState() { + if (this._isFinal) return; + + for (let id in this.splitlines) { + delete this.splitlines[id]; + } + for (let line of this.currentOrder.get_orderlines()) { + this.splitlines[line.id] = { quantity: 0 }; + } + this.newOrder.orderlines.reset(); + } + } + SplitBillScreen.template = 'SplitBillScreen'; + + Registries.Component.add(SplitBillScreen); + + return SplitBillScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/SplitBillScreen/SplitOrderline.js b/addons/pos_restaurant/static/src/js/Screens/SplitBillScreen/SplitOrderline.js new file mode 100644 index 00000000..51c0b929 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/SplitBillScreen/SplitOrderline.js @@ -0,0 +1,25 @@ +odoo.define('pos_restaurant.SplitOrderline', 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 SplitOrderline extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + get isSelected() { + return this.props.split.quantity !== 0; + } + onClick() { + this.trigger('click-line', this.props.line); + } + } + SplitOrderline.template = 'SplitOrderline'; + + Registries.Component.add(SplitOrderline); + + return SplitOrderline; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js b/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js new file mode 100644 index 00000000..cbea3d88 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js @@ -0,0 +1,158 @@ +odoo.define('pos_restaurant.TicketScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const TicketScreen = require('point_of_sale.TicketScreen'); + const Registries = require('point_of_sale.Registries'); + const { useAutofocus } = require('web.custom_hooks'); + const { posbus } = require('point_of_sale.utils'); + const { parse } = require('web.field_utils'); + const { useState, useContext } = owl.hooks; + + const PosResTicketScreen = (TicketScreen) => + class extends TicketScreen { + close() { + super.close(); + if (!this.env.pos.config.iface_floorplan) { + // Make sure the 'table-set' event is triggered + // to properly rerender the components that listens to it. + posbus.trigger('table-set'); + } + } + get filterOptions() { + const { Payment, Open, Tipping } = this.getOrderStates(); + var filterOptions = super.filterOptions; + if (this.env.pos.config.set_tip_after_payment) { + var idx = filterOptions.indexOf(Payment); + filterOptions[idx] = Open; + } + return [...filterOptions, Tipping]; + } + get _screenToStatusMap() { + const { Open, Tipping } = this.getOrderStates(); + return Object.assign(super._screenToStatusMap, { + PaymentScreen: this.env.pos.config.set_tip_after_payment ? Open : super._screenToStatusMap.PaymentScreen, + TipScreen: Tipping, + }); + } + getTable(order) { + return `${order.table.floor.name} (${order.table.name})`; + } + get _searchFields() { + if (!this.env.pos.config.iface_floorplan) { + return super._searchFields; + } + return Object.assign({}, super._searchFields, { + Table: (order) => `${order.table.floor.name} (${order.table.name})`, + }); + } + _setOrder(order) { + if (!this.env.pos.config.iface_floorplan) { + super._setOrder(order); + } else if (order !== this.env.pos.get_order()) { + // Only call set_table if the order is not the same as the current order. + // This is to prevent syncing to the server because syncing is only intended + // when going back to the floorscreen or opening a table. + this.env.pos.set_table(order.table, order); + } + } + get showNewTicketButton() { + return this.env.pos.config.iface_floorplan ? Boolean(this.env.pos.table) : super.showNewTicketButton; + } + get orderList() { + if (this.env.pos.table) { + return super.orderList; + } else { + return this.env.pos.get('orders').models; + } + } + async settleTips() { + // set tip in each order + for (const order of this.filteredOrderList) { + const tipAmount = parse.float(order.uiState.TipScreen.state.inputTipAmount || '0'); + const serverId = this.env.pos.validated_orders_name_server_id_map[order.name]; + if (!serverId) { + console.warn(`${order.name} is not yet sync. Sync it to server before setting a tip.`); + } else { + const result = await this.setTip(order, serverId, tipAmount); + if (!result) break; + } + } + } + async setTip(order, serverId, amount) { + try { + const paymentline = order.get_paymentlines()[0]; + if (paymentline.payment_method.payment_terminal) { + paymentline.amount += amount; + this.env.pos.set_order(order, {silent: true}); + await paymentline.payment_method.payment_terminal.send_payment_adjust(paymentline.cid); + } + + if (!amount) { + await this.setNoTip(); + } else { + order.finalized = false; + order.set_tip(amount); + order.finalized = true; + const tip_line = order.selected_orderline; + await this.rpc({ + method: 'set_tip', + model: 'pos.order', + args: [serverId, tip_line.export_as_JSON()], + }); + } + order.finalize(); + return true; + } catch (error) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: 'Failed to set tip', + body: `Failed to set tip to ${order.name}. Do you want to proceed on setting the tips of the remaining?`, + }); + return confirmed; + } + } + async setNoTip() { + await this.rpc({ + method: 'set_no_tip', + model: 'pos.order', + args: [serverId], + }); + } + getOrderStates() { + return Object.assign(super.getOrderStates(), { + Tipping: this.env._t('Tipping'), + Open: this.env._t('Open'), + }); + } + }; + + Registries.Component.extend(TicketScreen, PosResTicketScreen); + + class TipCell extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ isEditing: false }); + this.orderUiState = useContext(this.props.order.uiState.TipScreen); + useAutofocus({ selector: 'input' }); + } + get tipAmountStr() { + return this.env.pos.format_currency(parse.float(this.orderUiState.inputTipAmount || '0')); + } + onBlur() { + this.state.isEditing = false; + } + onKeydown(event) { + if (event.key === 'Enter') { + this.state.isEditing = false; + } + } + editTip() { + this.state.isEditing = true; + } + } + TipCell.template = 'TipCell'; + + Registries.Component.add(TipCell); + + return { TicketScreen, TipCell }; +}); diff --git a/addons/pos_restaurant/static/src/js/Screens/TipScreen.js b/addons/pos_restaurant/static/src/js/Screens/TipScreen.js new file mode 100644 index 00000000..bb6e17a2 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/TipScreen.js @@ -0,0 +1,156 @@ +odoo.define('pos_restaurant.TipScreen', function (require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + const PosComponent = require('point_of_sale.PosComponent'); + const { parse } = require('web.field_utils'); + const { useContext } = owl.hooks; + + class TipScreen extends PosComponent { + constructor() { + super(...arguments); + this.state = useContext(this.currentOrder.uiState.TipScreen); + this._totalAmount = this.currentOrder.get_total_with_tax(); + } + mounted () { + this.printTipReceipt(); + } + get overallAmountStr() { + const tipAmount = parse.float(this.state.inputTipAmount || '0'); + const original = this.env.pos.format_currency(this.totalAmount); + const tip = this.env.pos.format_currency(tipAmount); + const overall = this.env.pos.format_currency(this.totalAmount + tipAmount); + return `${original} + ${tip} tip = ${overall}`; + } + get totalAmount() { + return this._totalAmount; + } + get currentOrder() { + return this.env.pos.get_order(); + } + get percentageTips() { + return [ + { percentage: '15%', amount: 0.15 * this.totalAmount }, + { percentage: '20%', amount: 0.2 * this.totalAmount }, + { percentage: '25%', amount: 0.25 * this.totalAmount }, + ]; + } + async validateTip() { + const amount = parse.float(this.state.inputTipAmount) || 0; + const order = this.env.pos.get_order(); + const serverId = this.env.pos.validated_orders_name_server_id_map[order.name]; + + if (!serverId) { + this.showPopup('ErrorPopup', { + title: 'Unsynced order', + body: 'This order is not yet synced to server. Make sure it is synced then try again.', + }); + return; + } + + if (!amount) { + await this.rpc({ + method: 'set_no_tip', + model: 'pos.order', + args: [serverId], + }); + this.goNextScreen(); + return; + } + + if (amount > 0.25 * this.totalAmount) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: 'Are you sure?', + body: `${this.env.pos.format_currency( + amount + )} is more than 25% of the order's total amount. Are you sure of this tip amount?`, + }); + if (!confirmed) return; + } + + // set the tip by temporarily allowing order modification + order.finalized = false; + order.set_tip(amount); + order.finalized = true; + + const paymentline = this.env.pos.get_order().get_paymentlines()[0]; + if (paymentline.payment_method.payment_terminal) { + paymentline.amount += amount; + await paymentline.payment_method.payment_terminal.send_payment_adjust(paymentline.cid); + } + + // set_tip calls add_product which sets the new line as the selected_orderline + const tip_line = order.selected_orderline; + await this.rpc({ + method: 'set_tip', + model: 'pos.order', + args: [serverId, tip_line.export_as_JSON()], + }); + this.goNextScreen(); + } + goNextScreen() { + this.env.pos.get_order().finalize(); + const { name, props } = this.nextScreen; + this.showScreen(name, props); + } + get nextScreen() { + if (this.env.pos.config.module_pos_restaurant && this.env.pos.config.iface_floorplan) { + const table = this.env.pos.table; + return { name: 'FloorScreen', props: { floor: table ? table.floor : null } }; + } else { + return { name: 'ProductScreen' }; + } + } + async printTipReceipt() { + const receipts = [ + this.currentOrder.selected_paymentline.ticket, + this.currentOrder.selected_paymentline.cashier_receipt + ]; + + for (let i = 0; i < receipts.length; i++) { + const data = receipts[i]; + var receipt = this.env.qweb.renderToString('TipReceipt', { + receipt: this.currentOrder.getOrderReceiptEnv().receipt, + data: data, + total: this.env.pos.format_currency(this.totalAmount), + }); + + if (this.env.pos.proxy.printer) { + await this._printIoT(receipt); + } else { + await this._printWeb(receipt); + } + } + } + + async _printIoT(receipt) { + const printResult = await this.env.pos.proxy.printer.print_receipt(receipt); + if (!printResult.successful) { + await this.showPopup('ErrorPopup', { + title: printResult.message.title, + body: printResult.message.body, + }); + } + } + + async _printWeb(receipt) { + try { + $(this.el).find('.pos-receipt-container').html(receipt); + window.print(); + } 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.' + ), + }); + } + } + } + TipScreen.template = 'pos_restaurant.TipScreen'; + + Registries.Component.add(TipScreen); + + return TipScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/floors.js b/addons/pos_restaurant/static/src/js/floors.js new file mode 100644 index 00000000..3f5b739a --- /dev/null +++ b/addons/pos_restaurant/static/src/js/floors.js @@ -0,0 +1,399 @@ +odoo.define('pos_restaurant.floors', function (require) { +"use strict"; + +var models = require('point_of_sale.models'); +const { Gui } = require('point_of_sale.Gui'); +const { posbus } = require('point_of_sale.utils'); + +// At POS Startup, load the floors, and add them to the pos model +models.load_models({ + model: 'restaurant.floor', + fields: ['name','background_color','table_ids','sequence'], + domain: function(self){ return [['pos_config_id','=',self.config.id]]; }, + loaded: function(self,floors){ + self.floors = floors; + self.floors_by_id = {}; + for (var i = 0; i < floors.length; i++) { + floors[i].tables = []; + self.floors_by_id[floors[i].id] = floors[i]; + } + + // Make sure they display in the correct order + self.floors = self.floors.sort(function(a,b){ return a.sequence - b.sequence; }); + + // Ignore floorplan features if no floor specified. + self.config.iface_floorplan = !!self.floors.length; + }, +}); + +// At POS Startup, after the floors are loaded, load the tables, and associate +// them with their floor. +models.load_models({ + model: 'restaurant.table', + fields: ['name','width','height','position_h','position_v','shape','floor_id','color','seats'], + loaded: function(self,tables){ + self.tables_by_id = {}; + for (var i = 0; i < tables.length; i++) { + self.tables_by_id[tables[i].id] = tables[i]; + var floor = self.floors_by_id[tables[i].floor_id[0]]; + if (floor) { + floor.tables.push(tables[i]); + tables[i].floor = floor; + } + } + }, +}); + +// New orders are now associated with the current table, if any. +var _super_order = models.Order.prototype; +models.Order = models.Order.extend({ + initialize: function(attr,options) { + _super_order.initialize.apply(this,arguments); + if (!this.table && !options.json) { + this.table = this.pos.table; + } + this.customer_count = this.customer_count || 1; + this.save_to_db(); + }, + export_as_JSON: function() { + var json = _super_order.export_as_JSON.apply(this,arguments); + json.table = this.table ? this.table.name : undefined; + json.table_id = this.table ? this.table.id : false; + json.floor = this.table ? this.table.floor.name : false; + json.floor_id = this.table ? this.table.floor.id : false; + json.customer_count = this.customer_count; + return json; + }, + init_from_JSON: function(json) { + _super_order.init_from_JSON.apply(this,arguments); + this.table = this.pos.tables_by_id[json.table_id]; + this.floor = this.table ? this.pos.floors_by_id[json.floor_id] : undefined; + this.customer_count = json.customer_count || 1; + }, + export_for_printing: function() { + var json = _super_order.export_for_printing.apply(this,arguments); + json.table = this.table ? this.table.name : undefined; + json.floor = this.table ? this.table.floor.name : undefined; + json.customer_count = this.get_customer_count(); + return json; + }, + get_customer_count: function(){ + return this.customer_count; + }, + set_customer_count: function(count) { + this.customer_count = Math.max(count,0); + this.trigger('change'); + }, +}); + +// We need to change the way the regular UI sees the orders, it +// needs to only see the orders associated with the current table, +// and when an order is validated, it needs to go back to the floor map. +// +// And when we change the table, we must create an order for that table +// if there is none. +var _super_posmodel = models.PosModel.prototype; +models.PosModel = models.PosModel.extend({ + after_load_server_data: async function() { + var res = await _super_posmodel.after_load_server_data.call(this); + if (this.config.iface_floorplan) { + this.table = null; + } + return res; + }, + + transfer_order_to_different_table: function () { + this.order_to_transfer_to_different_table = this.get_order(); + + // go to 'floors' screen, this will set the order to null and + // eventually this will cause the gui to go to its + // default_screen, which is 'floors' + this.set_table(null); + }, + + remove_from_server_and_set_sync_state: function(ids_to_remove){ + var self = this; + this.set_synch('connecting', ids_to_remove.length); + return self._remove_from_server(ids_to_remove) + .then(function(server_ids) { + self.set_synch('connected'); + }).catch(function(reason){ + self.set_synch('error'); + throw reason; + }); + }, + + /** + * Request the orders of the table with given id. + * @param {number} table_id. + * @param {dict} options. + * @param {number} options.timeout optional timeout parameter for the rpc call. + * @return {Promise} + */ + _get_from_server: function (table_id, options) { + options = options || {}; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500; + return this.rpc({ + model: 'pos.order', + method: 'get_table_draft_orders', + args: [table_id], + kwargs: {context: this.session.user_context}, + }, { + timeout: timeout, + shadow: false, + }) + }, + + transfer_order_to_table: function(table) { + this.order_to_transfer_to_different_table.table = table; + this.order_to_transfer_to_different_table.save_to_db(); + }, + + push_order_for_transfer: function(order_ids, table_orders) { + order_ids.push(this.order_to_transfer_to_different_table.uid); + table_orders.push(this.order_to_transfer_to_different_table); + }, + + clean_table_transfer: function(table) { + if (this.order_to_transfer_to_different_table && table) { + this.order_to_transfer_to_different_table = null; + this.set_table(table); + } + }, + + sync_from_server: function(table, table_orders, order_ids) { + var self = this; + var ids_to_remove = this.db.get_ids_to_remove_from_server(); + var orders_to_sync = this.db.get_unpaid_orders_to_sync(order_ids); + if (orders_to_sync.length) { + this.set_synch('connecting', orders_to_sync.length); + this._save_to_server(orders_to_sync, {'draft': true}).then(function (server_ids) { + server_ids.forEach(server_id => self.update_table_order(server_id, table_orders)); + if (!ids_to_remove.length) { + self.set_synch('connected'); + } else { + self.remove_from_server_and_set_sync_state(ids_to_remove); + } + }).catch(function(reason){ + self.set_synch('error'); + }).finally(function(){ + self.clean_table_transfer(table); + }); + } else { + if (ids_to_remove.length) { + self.remove_from_server_and_set_sync_state(ids_to_remove); + } + self.clean_table_transfer(table); + } + }, + + update_table_order: function(server_id, table_orders) { + const order = table_orders.find(o => o.name === server_id.pos_reference); + if (order) { + order.server_id = server_id.id; + order.save_to_db(); + } + return order; + }, + + /** + * @param {models.Order} order order to set + */ + set_order_on_table: function(order) { + var orders = this.get_order_list(); + if (orders.length) { + order = order ? orders.find((o) => o.uid === order.uid) : null; + if (order) { + this.set_order(order); + } else { + // do not mindlessly set the first order in the list. + orders = orders.filter(order => !order.finalized); + if (orders.length) { + this.set_order(orders[0]); + } else { + this.add_new_order(); + } + } + } else { + this.add_new_order(); // or create a new order with the current table + } + }, + + sync_to_server: function(table, order) { + var self = this; + var ids_to_remove = this.db.get_ids_to_remove_from_server(); + + this.set_synch('connecting', 1); + this._get_from_server(table.id).then(function (server_orders) { + var orders = self.get_order_list(); + orders.forEach(function(order){ + // We don't remove the validated orders because we still want to see them + // in the ticket screen. Orders in 'ReceiptScreen' or 'TipScreen' are validated + // orders. + if (order.server_id && !order.finalized){ + self.get("orders").remove(order); + order.destroy(); + } + }); + server_orders.forEach(function(server_order){ + if (server_order.lines.length){ + var new_order = new models.Order({},{pos: self, json: server_order}); + self.get("orders").add(new_order); + new_order.save_to_db(); + } + }) + if (!ids_to_remove.length) { + self.set_synch('connected'); + } else { + self.remove_from_server_and_set_sync_state(ids_to_remove); + } + }).catch(function(reason){ + self.set_synch('error'); + }).finally(function(){ + self.set_order_on_table(order); + }); + }, + + get_order_with_uid: function() { + var order_ids = []; + this.get_order_list().forEach(function(o){ + order_ids.push(o.uid); + }); + + return order_ids; + }, + + /** + * Changes the current table. + * + * Switch table and make sure all nececery syncing tasks are done. + * @param {object} table. + * @param {models.Order|undefined} order if provided, set to this order + */ + set_table: function(table, order) { + if(!table){ + this.sync_from_server(table, this.get_order_list(), this.get_order_with_uid()); + this.set_order(null); + this.table = null; + } else if (this.order_to_transfer_to_different_table) { + var order_ids = this.get_order_with_uid(); + + this.transfer_order_to_table(table); + this.push_order_for_transfer(order_ids, this.get_order_list()); + + this.sync_from_server(table, this.get_order_list(), order_ids); + this.set_order(null); + } else { + this.table = table; + this.sync_to_server(table, order); + } + posbus.trigger('table-set'); + }, + + // if we have tables, we do not load a default order, as the default order will be + // set when the user selects a table. + set_start_order: function() { + if (!this.config.iface_floorplan) { + _super_posmodel.set_start_order.apply(this,arguments); + } + }, + + // we need to prevent the creation of orders when there is no + // table selected. + add_new_order: function() { + if (this.config.iface_floorplan) { + if (this.table) { + return _super_posmodel.add_new_order.apply(this, arguments); + } else { + Gui.showPopup('ConfirmPopup', { + title: 'Unable to create order', + body: 'Orders cannot be created when there is no active table in restaurant mode', + }); + return undefined; + } + } else { + return _super_posmodel.add_new_order.apply(this,arguments); + } + }, + + + // get the list of unpaid orders (associated to the current table) + get_order_list: function() { + var orders = _super_posmodel.get_order_list.call(this); + if (!(this.config && this.config.iface_floorplan)) { + return orders; + } else if (!this.table) { + return []; + } else { + var t_orders = []; + for (var i = 0; i < orders.length; i++) { + if ( orders[i].table === this.table) { + t_orders.push(orders[i]); + } + } + return t_orders; + } + }, + + // get the list of orders associated to a table. FIXME: should be O(1) + get_table_orders: function(table) { + var orders = _super_posmodel.get_order_list.call(this); + var t_orders = []; + for (var i = 0; i < orders.length; i++) { + if (orders[i].table === table) { + t_orders.push(orders[i]); + } + } + return t_orders; + }, + + // get customer count at table + get_customer_count: function(table) { + var orders = this.get_table_orders(table).filter(order => !order.finalized); + var count = 0; + for (var i = 0; i < orders.length; i++) { + count += orders[i].get_customer_count(); + } + return count; + }, + + // When we validate an order we go back to the floor plan. + // When we cancel an order and there is multiple orders + // on the table, stay on the table. + on_removed_order: function(removed_order,index,reason){ + if (this.config.iface_floorplan) { + var order_list = this.get_order_list(); + if (reason === 'abandon') { + this.db.set_order_to_remove_from_server(removed_order); + } + if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){ + this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true }); + } else if (order_list.length === 0) { + this.table ? this.set_order(null) : this.set_table(null); + } + } else { + _super_posmodel.on_removed_order.apply(this,arguments); + } + }, + + +}); + + +var _super_paymentline = models.Paymentline.prototype; +models.Paymentline = models.Paymentline.extend({ + /** + * Override this method to be able to show the 'Adjust Authorisation' button + * on a validated payment_line and to show the tip screen which allow + * tipping even after payment. By default, this returns true for all + * non-cash payment. + */ + canBeAdjusted: function() { + if (this.payment_method.payment_terminal) { + return this.payment_method.payment_terminal.canBeAdjusted(this.cid); + } + return !this.payment_method.is_cash_count; + }, +}); + +}); diff --git a/addons/pos_restaurant/static/src/js/multiprint.js b/addons/pos_restaurant/static/src/js/multiprint.js new file mode 100644 index 00000000..709de43e --- /dev/null +++ b/addons/pos_restaurant/static/src/js/multiprint.js @@ -0,0 +1,315 @@ +odoo.define('pos_restaurant.multiprint', function (require) { +"use strict"; + +var models = require('point_of_sale.models'); +var core = require('web.core'); +var Printer = require('point_of_sale.Printer').Printer; + +var QWeb = core.qweb; + +models.PosModel = models.PosModel.extend({ + create_printer: function (config) { + var url = config.proxy_ip || ''; + if(url.indexOf('//') < 0) { + url = window.location.protocol + '//' + url; + } + if(url.indexOf(':', url.indexOf('//') + 2) < 0 && window.location.protocol !== 'https:') { + url = url + ':8069'; + } + return new Printer(url, this); + }, +}); + +models.load_models({ + model: 'restaurant.printer', + fields: ['name','proxy_ip','product_categories_ids', 'printer_type'], + domain: null, + loaded: function(self,printers){ + var active_printers = {}; + for (var i = 0; i < self.config.printer_ids.length; i++) { + active_printers[self.config.printer_ids[i]] = true; + } + + self.printers = []; + self.printers_categories = {}; // list of product categories that belong to + // one or more order printer + + for(var i = 0; i < printers.length; i++){ + if(active_printers[printers[i].id]){ + var printer = self.create_printer(printers[i]); + printer.config = printers[i]; + self.printers.push(printer); + + for (var j = 0; j < printer.config.product_categories_ids.length; j++) { + self.printers_categories[printer.config.product_categories_ids[j]] = true; + } + } + } + self.printers_categories = _.keys(self.printers_categories); + self.config.iface_printers = !!self.printers.length; + }, +}); + +var _super_orderline = models.Orderline.prototype; + +models.Orderline = models.Orderline.extend({ + initialize: function() { + _super_orderline.initialize.apply(this,arguments); + if (!this.pos.config.iface_printers) { + return; + } + if (typeof this.mp_dirty === 'undefined') { + // mp dirty is true if this orderline has changed + // since the last kitchen print + // it's left undefined if the orderline does not + // need to be printed to a printer. + + this.mp_dirty = this.printable() || undefined; + } + if (!this.mp_skip) { + // mp_skip is true if the cashier want this orderline + // not to be sent to the kitchen + this.mp_skip = false; + } + }, + // can this orderline be potentially printed ? + printable: function() { + return this.pos.db.is_product_in_category(this.pos.printers_categories, this.get_product().id); + }, + init_from_JSON: function(json) { + _super_orderline.init_from_JSON.apply(this,arguments); + this.mp_dirty = json.mp_dirty; + this.mp_skip = json.mp_skip; + }, + export_as_JSON: function() { + var json = _super_orderline.export_as_JSON.apply(this,arguments); + json.mp_dirty = this.mp_dirty; + json.mp_skip = this.mp_skip; + return json; + }, + set_quantity: function(quantity) { + if (this.pos.config.iface_printers && quantity !== this.quantity && this.printable()) { + this.mp_dirty = true; + } + _super_orderline.set_quantity.apply(this,arguments); + }, + can_be_merged_with: function(orderline) { + return (!this.mp_skip) && + (!orderline.mp_skip) && + _super_orderline.can_be_merged_with.apply(this,arguments); + }, + set_skip: function(skip) { + if (this.mp_dirty && skip && !this.mp_skip) { + this.mp_skip = true; + this.trigger('change',this); + } + if (this.mp_skip && !skip) { + this.mp_dirty = true; + this.mp_skip = false; + this.trigger('change',this); + } + }, + set_dirty: function(dirty) { + if (this.mp_dirty !== dirty) { + this.mp_dirty = dirty; + this.trigger('change', this); + } + }, + get_line_diff_hash: function(){ + if (this.get_note()) { + return this.id + '|' + this.get_note(); + } else { + return '' + this.id; + } + }, +}); + +var _super_order = models.Order.prototype; +models.Order = models.Order.extend({ + build_line_resume: function(){ + var resume = {}; + this.orderlines.each(function(line){ + if (line.mp_skip) { + return; + } + var line_hash = line.get_line_diff_hash(); + var qty = Number(line.get_quantity()); + var note = line.get_note(); + var product_id = line.get_product().id; + + if (typeof resume[line_hash] === 'undefined') { + resume[line_hash] = { + qty: qty, + note: note, + product_id: product_id, + product_name_wrapped: line.generate_wrapped_product_name(), + }; + } else { + resume[line_hash].qty += qty; + } + + }); + return resume; + }, + saveChanges: function(){ + this.saved_resume = this.build_line_resume(); + this.orderlines.each(function(line){ + line.set_dirty(false); + }); + this.trigger('change',this); + }, + computeChanges: function(categories){ + var current_res = this.build_line_resume(); + var old_res = this.saved_resume || {}; + var json = this.export_as_JSON(); + var add = []; + var rem = []; + var line_hash; + + for ( line_hash in current_res) { + var curr = current_res[line_hash]; + var old = {}; + var found = false; + for(var id in old_res) { + if(old_res[id].product_id === curr.product_id){ + found = true; + old = old_res[id]; + break; + } + } + + if (!found) { + add.push({ + 'id': curr.product_id, + 'name': this.pos.db.get_product_by_id(curr.product_id).display_name, + 'name_wrapped': curr.product_name_wrapped, + 'note': curr.note, + 'qty': curr.qty, + }); + } else if (old.qty < curr.qty) { + add.push({ + 'id': curr.product_id, + 'name': this.pos.db.get_product_by_id(curr.product_id).display_name, + 'name_wrapped': curr.product_name_wrapped, + 'note': curr.note, + 'qty': curr.qty - old.qty, + }); + } else if (old.qty > curr.qty) { + rem.push({ + 'id': curr.product_id, + 'name': this.pos.db.get_product_by_id(curr.product_id).display_name, + 'name_wrapped': curr.product_name_wrapped, + 'note': curr.note, + 'qty': old.qty - curr.qty, + }); + } + } + + for (line_hash in old_res) { + var found = false; + for(var id in current_res) { + if(current_res[id].product_id === old_res[line_hash].product_id) + found = true; + } + if (!found) { + var old = old_res[line_hash]; + rem.push({ + 'id': old.product_id, + 'name': this.pos.db.get_product_by_id(old.product_id).display_name, + 'name_wrapped': old.product_name_wrapped, + 'note': old.note, + 'qty': old.qty, + }); + } + } + + if(categories && categories.length > 0){ + // filter the added and removed orders to only contains + // products that belong to one of the categories supplied as a parameter + + var self = this; + + var _add = []; + var _rem = []; + + for(var i = 0; i < add.length; i++){ + if(self.pos.db.is_product_in_category(categories,add[i].id)){ + _add.push(add[i]); + } + } + add = _add; + + for(var i = 0; i < rem.length; i++){ + if(self.pos.db.is_product_in_category(categories,rem[i].id)){ + _rem.push(rem[i]); + } + } + rem = _rem; + } + + var d = new Date(); + var hours = '' + d.getHours(); + hours = hours.length < 2 ? ('0' + hours) : hours; + var minutes = '' + d.getMinutes(); + minutes = minutes.length < 2 ? ('0' + minutes) : minutes; + + return { + 'new': add, + 'cancelled': rem, + 'table': json.table || false, + 'floor': json.floor || false, + 'name': json.name || 'unknown order', + 'time': { + 'hours': hours, + 'minutes': minutes, + }, + }; + + }, + printChanges: async function(){ + var printers = this.pos.printers; + let isPrintSuccessful = true; + for(var i = 0; i < printers.length; i++){ + var changes = this.computeChanges(printers[i].config.product_categories_ids); + if ( changes['new'].length > 0 || changes['cancelled'].length > 0){ + var receipt = QWeb.render('OrderChangeReceipt',{changes:changes, widget:this}); + const result = await printers[i].print_receipt(receipt); + if (!result.successful) { + isPrintSuccessful = false; + } + } + } + return isPrintSuccessful; + }, + hasChangesToPrint: function(){ + var printers = this.pos.printers; + for(var i = 0; i < printers.length; i++){ + var changes = this.computeChanges(printers[i].config.product_categories_ids); + if ( changes['new'].length > 0 || changes['cancelled'].length > 0){ + return true; + } + } + return false; + }, + hasSkippedChanges: function() { + var orderlines = this.get_orderlines(); + for (var i = 0; i < orderlines.length; i++) { + if (orderlines[i].mp_skip) { + return true; + } + } + return false; + }, + export_as_JSON: function(){ + var json = _super_order.export_as_JSON.apply(this,arguments); + json.multiprint_resume = JSON.stringify(this.saved_resume); + return json; + }, + init_from_JSON: function(json){ + _super_order.init_from_JSON.apply(this,arguments); + this.saved_resume = json.multiprint_resume && JSON.parse(json.multiprint_resume); + }, +}); + + +}); diff --git a/addons/pos_restaurant/static/src/js/notes.js b/addons/pos_restaurant/static/src/js/notes.js new file mode 100644 index 00000000..c2ed95dd --- /dev/null +++ b/addons/pos_restaurant/static/src/js/notes.js @@ -0,0 +1,42 @@ +odoo.define('pos_restaurant.notes', function (require) { +"use strict"; + +var models = require('point_of_sale.models'); + +var _super_orderline = models.Orderline.prototype; +models.Orderline = models.Orderline.extend({ + initialize: function(attr, options) { + _super_orderline.initialize.call(this,attr,options); + this.note = this.note || ""; + }, + set_note: function(note){ + this.note = note; + this.trigger('change',this); + }, + get_note: function(note){ + return this.note; + }, + can_be_merged_with: function(orderline) { + if (orderline.get_note() !== this.get_note()) { + return false; + } else { + return _super_orderline.can_be_merged_with.apply(this,arguments); + } + }, + clone: function(){ + var orderline = _super_orderline.clone.call(this); + orderline.note = this.note; + return orderline; + }, + export_as_JSON: function(){ + var json = _super_orderline.export_as_JSON.call(this); + json.note = this.note; + return json; + }, + init_from_JSON: function(json){ + _super_orderline.init_from_JSON.apply(this,arguments); + this.note = json.note; + }, +}); + +}); diff --git a/addons/pos_restaurant/static/src/js/payment.js b/addons/pos_restaurant/static/src/js/payment.js new file mode 100644 index 00000000..355d6742 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/payment.js @@ -0,0 +1,24 @@ +odoo.define('pos_restaurant.PaymentInterface', function (require) { + "use strict"; + + var PaymentInterface = require('point_of_sale.PaymentInterface'); + + PaymentInterface.include({ + /** + * Return true if the amount that was authorized can be modified, + * false otherwise + * @param {string} cid - The id of the paymentline + */ + canBeAdjusted(cid) { + return false; + }, + + /** + * Called when the amount authorized by a payment request should + * be adjusted to account for a new order line, it can only be called if + * canBeAdjusted returns True + * @param {string} cid - The id of the paymentline + */ + send_payment_adjust: function (cid) {}, + }); +}); |
