Initial commit: Pos packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 95dfb9edb0
1301 changed files with 264148 additions and 0 deletions

View file

@ -0,0 +1,94 @@
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;
}
}
_setActivityListeners() {
IDLE_TIMER_SETTER = this._setIdleTimer.bind(this);
for (const event of NON_IDLE_EVENTS) {
window.addEventListener(event, IDLE_TIMER_SETTER);
}
}
_setIdleTimer() {
clearTimeout(this.idleTimer);
if (this._shouldResetIdleTimer()) {
this.idleTimer = setTimeout(() => {
this._actionAfterIdle();
}, 60000);
}
}
_actionAfterIdle() {
if (this.tempScreen.isShown) {
this.trigger('close-temp-screen');
}
const table = this.env.pos.table;
const order = this.env.pos.get_order();
if (order && order.get_screen_data().name === 'ReceiptScreen') {
// When the order is finalized, we can safely remove it from the memory
// We check that it's in ReceiptScreen because we want to keep the order if it's in a tipping state
this.env.pos.removeOrder(order);
}
this.showScreen('FloorScreen', { floor: table ? table.floor : null });
}
_shouldResetIdleTimer() {
const stayPaymentScreen = this.mainScreen.name === 'PaymentScreen' && this.env.pos.get_order().paymentlines.length > 0;
return this.env.pos.config.iface_floorplan && !stayPaymentScreen && 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;
});

View file

@ -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');
/**
* Props: {
* onClick: callback
* }
*/
class BackToFloorButton extends PosComponent {
get table() {
return this.env.pos.table;
}
get floor() {
return this.table ? this.table.floor : null;
}
get hasTable() {
return this.table != null;
}
backToFloorScreen() {
if (this.props.onClick) {
this.props.onClick();
}
this.showScreen('FloorScreen', { floor: this.floor });
}
}
BackToFloorButton.template = 'BackToFloorButton';
Registries.Component.add(BackToFloorButton);
return BackToFloorButton;
});

View file

@ -0,0 +1,52 @@
odoo.define('pos_restaurant.TicketButton', function (require) {
'use strict';
const TicketButton = require('point_of_sale.TicketButton');
const Registries = require('point_of_sale.Registries');
const { isConnectionError } = require('point_of_sale.utils');
const PosResTicketButton = (TicketButton) =>
class extends TicketButton {
async onClick() {
if (this.env.pos.config.iface_floorplan && !this.props.isTicketScreenShown && !this.env.pos.table) {
try {
this.env.pos.setLoadingOrderState(true);
await this.env.pos._syncAllOrdersFromServer();
} catch (error) {
if (isConnectionError(error)) {
await this.showPopup('OfflineErrorPopup', {
title: this.env._t('Offline'),
body: this.env._t('Due to a connection error, the orders are not synchronized.'),
});
} else {
this.showPopup('ErrorPopup', {
title: this.env._t('Unknown error'),
body: error.message,
});
}
} finally {
this.env.pos.setLoadingOrderState(false);
this.showScreen('TicketScreen');
}
} else {
super.onClick();
}
}
/**
* 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.getTableOrders(this.env.pos.table.id).length;
} else {
return super.count;
}
}
};
Registries.Component.extend(TicketButton, PosResTicketButton);
return TicketButton;
});

View file

@ -0,0 +1,331 @@
odoo.define('pos_restaurant.Resizeable', function(require) {
'use strict';
const { useListener } = require("@web/core/utils/hooks");
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { onMounted, useExternalListener } = owl;
class Resizeable extends PosComponent {
setup() {
super.setup();
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 = {};
onMounted(() => {
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;
});

View file

@ -0,0 +1,35 @@
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() {
const currentOrder = this.currentOrder;
await super.printReceipt();
currentOrder._printed = false;
if (this.env.pos.config.iface_print_skip_screen && !this.env.isMobile) {
this.confirm();
}
}
}
BillScreen.template = 'BillScreen';
return BillScreen;
};
Registries.Component.addByExtending(BillScreen, ReceiptScreen);
return BillScreen;
});

View file

@ -0,0 +1,20 @@
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;
class EditBar extends PosComponent {
setup() {
super.setup();
this.state = useState({ isColorPicker: false })
}
}
EditBar.template = 'EditBar';
Registries.Component.add(EditBar);
return EditBar;
});

View file

@ -0,0 +1,61 @@
odoo.define('pos_restaurant.EditableTable', function(require) {
'use strict';
const { useListener } = require("@web/core/utils/hooks");
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { onMounted, onPatched } = owl;
class EditableTable extends PosComponent {
setup() {
super.setup();
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.props.onSaveTable(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.props.onSaveTable(this.props.table);
}
}
EditableTable.template = 'EditableTable';
Registries.Component.add(EditableTable);
return EditableTable;
});

View file

@ -0,0 +1,363 @@
odoo.define('pos_restaurant.FloorScreen', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { debounce } = require("@web/core/utils/timing");
const { isConnectionError } = require('point_of_sale.utils');
const { onPatched, onMounted, onWillUnmount, useRef, useState } = owl;
class FloorScreen extends PosComponent {
/**
* @param {Object} props
* @param {Object} props.floor
*/
setup() {
super.setup();
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');
onPatched(this.onPatched);
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
}
onPatched() {
this.floorMapRef.el.style.background = this.state.floorBackground;
this.state.floorMapScrollTop = this.floorMapRef.el.getBoundingClientRect().top;
}
onMounted() {
if (this.env.pos.table) {
this.env.pos.unsetTable();
}
this.env.posbus.trigger('start-cash-control');
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);
}
onWillUnmount() {
clearInterval(this.tableLongpolling);
}
_computePinchHypo(ev, callbackFunction) {
const touches = ev.touches;
// If two pointers are down, check for pinch gestures
if (touches.length === 2) {
const deltaX = touches[0].pageX - touches[1].pageX;
const deltaY = touches[0].pageY - touches[1].pageY;
callbackFunction(Math.hypot(deltaX, deltaY))
}
}
_onPinchStart(ev) {
ev.currentTarget.style.setProperty('touch-action', 'none');
this._computePinchHypo(ev, this.startPinch.bind(this));
}
_onPinchEnd(ev) {
ev.currentTarget.style.removeProperty('touch-action');
}
_onPinchMove(ev) {
debounce(this._computePinchHypo, 10, true)(ev, this.movePinch.bind(this));
}
_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 (isConnectionError(error)) {
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 tableCopy = { ...table };
delete tableCopy.floor;
const tableId = await this.rpc({
model: 'restaurant.table',
method: 'create_from_ui',
args: [tableCopy],
});
table.id = tableId;
this.env.pos.tables_by_id[tableId] = 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];
if (table_obj === undefined) {
console.warn(`Table with id ${table.id} is not found in the POS`);
return; // skip the table
}
const unsynced_orders = this.env.pos
.getTableOrders(table_obj.id)
.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;
});
} catch (error) {
if (isConnectionError(error)) {
await this.showPopup('OfflineErrorPopup', {
title: this.env._t('Offline'),
body: this.env._t('Unable to get orders count'),
});
} else {
throw error;
}
}
}
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;
}
movePinch(hypot) {
const delta = hypot / this.scalehypot ;
const value = this.initalScale * delta;
this.setScale(value);
}
startPinch(hypot) {
this.scalehypot = hypot;
this.initalScale = this.getScale();
}
getMapNode() {
return this.el.querySelector('.floor-map > .tables, .floor-map > .empty-floor');
}
getScale() {
const scale = this.getMapNode().style.getPropertyValue('--scale');
const parsedScaleValue = parseFloat(scale);
return isNaN(parsedScaleValue) ? 1 : parsedScaleValue;
}
setScale(value) {
// a scale can't be a negative number
if (value > 0) {
this.getMapNode().style.setProperty('--scale', value);
}
}
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 onSelectTable(table) {
if (this.state.isEditMode) {
this.state.selectedTableId = table.id;
} else {
try {
if (this.env.pos.orderToTransfer) {
await this.env.pos.transferTable(table);
} else {
await this.env.pos.setTable(table);
}
} catch (error) {
if (isConnectionError(error)) {
await this.showPopup('OfflineErrorPopup', {
title: this.env._t('Offline'),
body: this.env._t('Unable to fetch orders'),
});
} else {
throw error;
}
}
const order = this.env.pos.get_order();
this.showScreen(order.get_screen_data().name);
}
}
async onSaveTable(table) {
await this._save(table);
}
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 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 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 ?'),
isInputSelected: true,
});
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 setTableColor(color) {
this.selectedTable.color = color;
this.render();
await this._save(this.selectedTable);
}
async setFloorColor(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 (isConnectionError(error)) {
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;
}
delete this.env.pos.tables_by_id[originalSelectedTableId];
this.env.pos.TICKET_SCREEN_STATE.syncedOrders.cache = {};
} catch (error) {
if (isConnectionError(error)) {
await this.showPopup('OfflineErrorPopup', {
title: this.env._t('Offline'),
body: this.env._t('Unable to delete table'),
});
} else {
throw error;
}
}
}
}
FloorScreen.template = 'FloorScreen';
FloorScreen.hideOrderSelector = true;
Registries.Component.add(FloorScreen);
return FloorScreen;
});

View file

@ -0,0 +1,86 @@
odoo.define('pos_restaurant.TableWidget', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* props: {
* onClick: callback,
* table: table object,
* }
*/
class TableWidget extends PosComponent {
setup() {
owl.onMounted(this.onMounted);
}
onMounted() {
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.getCustomerCount(this.props.table.id);
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
.getTableOrders(table.id)
.filter(o => o.orderlines.length !== 0 || o.paymentlines.length !== 0).length;
}
get orderCountClass() {
const countClass = { 'order-count': true }
if (this.env.pos.config.iface_printers) {
const notifications = this._getNotifications();
countClass['notify-printing'] = notifications.printing;
countClass['notify-skipped'] = notifications.skipped;
}
return countClass;
}
get customerCountDisplay() {
return `${this.env.pos.getCustomerCount(this.props.table.id)}/${this.props.table.seats}`;
}
_getNotifications() {
const orders = this.env.pos.getTableOrders(this.props.table.id);
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;
});

View file

@ -0,0 +1,48 @@
odoo.define('pos_restaurant.PosResPaymentScreen', function (require) {
'use strict';
const PaymentScreen = require('point_of_sale.PaymentScreen');
const { useListener } = require("@web/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
const PosResPaymentScreen = (PaymentScreen) =>
class extends PaymentScreen {
setup() {
super.setup();
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;
});

View file

@ -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/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class OrderlineNoteButton extends PosComponent {
setup() {
super.setup();
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 Internal Note'),
});
if (confirmed) {
this.selectedOrderline.set_note(inputNote);
}
}
}
OrderlineNoteButton.template = 'OrderlineNoteButton';
ProductScreen.addControlButton({
component: OrderlineNoteButton,
condition: function() {
return this.env.pos.config.iface_orderline_notes;
},
});
Registries.Component.add(OrderlineNoteButton);
return OrderlineNoteButton;
});

View file

@ -0,0 +1,39 @@
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/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class PrintBillButton extends PosComponent {
setup() {
super.setup();
useListener('click', this.onClick);
}
async onClick() {
const order = this.env.pos.get_order();
if (order.get_orderlines().length > 0) {
order.initialize_validation_date();
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;
});

View file

@ -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/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class SplitBillButton extends PosComponent {
setup() {
super.setup();
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;
});

View file

@ -0,0 +1,65 @@
odoo.define('pos_restaurant.SubmitOrderButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ProductScreen = require('point_of_sale.ProductScreen');
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 {
setup() {
super.setup();
this.clicked = false; //mutex, we don't want to be able to spam the printers
}
async _onClick() {
if (!this.clicked) {
try {
this.clicked = true;
const order = this.env.pos.get_order();
if (order.hasChangesToPrint()) {
const isPrintSuccessful = await order.printChanges();
if (isPrintSuccessful) {
order.updatePrintedResume();
} else {
this.showPopup('ErrorPopup', {
title: this.env._t('Printing failed'),
body: this.env._t('Failed in printing the changes in the order'),
});
}
}
} finally {
this.clicked = false;
}
}
}
get currentOrder() {
return this.env.pos.get_order();
}
get addedClasses() {
if (!this.currentOrder) return {};
const hasChanges = this.currentOrder.hasChangesToPrint();
const skipped = hasChanges ? false : this.currentOrder.hasSkippedChanges();
return {
highlight: hasChanges,
altlight: skipped,
};
}
}
SubmitOrderButton.template = 'SubmitOrderButton';
ProductScreen.addControlButton({
component: SubmitOrderButton,
condition: function() {
return this.env.pos.config.module_pos_restaurant && this.env.pos.unwatched.printers.length;
},
});
Registries.Component.add(SubmitOrderButton);
return SubmitOrderButton;
});

View file

@ -0,0 +1,58 @@
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/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class TableGuestsButton extends PosComponent {
setup() {
super.setup();
useListener('click', this.onClick);
}
get currentOrder() {
return this.env.pos.get_order();
}
get nGuests() {
return this.currentOrder ? this.currentOrder.getCustomerCount() : 0;
}
async onClick() {
const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', {
startingValue: this.nGuests,
cheap: true,
title: this.env._t('Guests ?'),
isInputSelected: true
});
if (confirmed) {
const guestCount = parseInt(inputNumber, 10) || 1;
// Set the maximum number possible for an integer
const max_capacity = 2**31 - 1;
if (guestCount > max_capacity) {
await this.showPopup('ErrorPopup', {
title: this.env._t('Blocked action'),
body: _.str.sprintf(
this.env._t('You cannot put a number that exceeds %s '),
max_capacity,
),
});
return;
}
this.env.pos.get_order().setCustomerCount(guestCount);
}
}
}
TableGuestsButton.template = 'TableGuestsButton';
ProductScreen.addControlButton({
component: TableGuestsButton,
condition: function() {
return this.env.pos.config.module_pos_restaurant;
},
});
Registries.Component.add(TableGuestsButton);
return TableGuestsButton;
});

View file

@ -0,0 +1,31 @@
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/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class TransferOrderButton extends PosComponent {
setup() {
super.setup();
useListener('click', this.onClick);
}
async onClick() {
this.env.pos.setCurrentOrderToTransfer();
this.showScreen('FloorScreen');
}
}
TransferOrderButton.template = 'TransferOrderButton';
ProductScreen.addControlButton({
component: TransferOrderButton,
condition: function() {
return this.env.pos.config.iface_floorplan;
},
});
Registries.Component.add(TransferOrderButton);
return TransferOrderButton;
});

View file

@ -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.id !== line.id) {
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;
});

View file

@ -0,0 +1,33 @@
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
_addNewOrder() {
if (!this.env.pos.config.iface_floorplan) {
super._addNewOrder();
}
}
//@override
get nextScreen() {
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.nextScreen;
}
}
onBackToFloorButtonClick() {
// If we're here and the order is paid, we can remove it from the orders
this.env.pos.removeOrder(this.currentOrder);
}
};
Registries.Component.extend(ReceiptScreen, PosResReceiptScreen);
return ReceiptScreen;
});

View file

@ -0,0 +1,184 @@
odoo.define('pos_restaurant.SplitBillScreen', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const { useListener } = require("@web/core/utils/hooks");
const { Order } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
const { useState, onMounted } = owl;
class SplitBillScreen extends PosComponent {
setup() {
super.setup();
useListener('click-line', this.onClickLine);
this.splitlines = useState(this._initSplitLines(this.env.pos.get_order()));
this.newOrderLines = {};
this.newOrder = undefined;
this._isFinal = false;
onMounted(() => {
// Should create the new order outside of the constructor because
// sequence_number of pos_session is modified. which will trigger
// rerendering which will rerender this screen and will be infinite loop.
this.newOrder = Order.create(
{},
{
pos: this.env.pos,
temporary: true,
}
);
this.render();
});
}
get disallow() {
return false;
}
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._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.env.pos.config.iface_printers) {
this.currentOrder.updatePrintedResume();
this.newOrder.updatePrintedResume();
}
this.newOrder.setCustomerCount(1);
const newCustomerCount = this.currentOrder.getCustomerCount() - 1;
this.currentOrder.setCustomerCount(newCustomerCount || 1);
this.currentOrder.set_screen_data({ name: 'ProductScreen' });
const reactiveNewOrder = this.env.pos.makeOrderReactive(this.newOrder);
this.env.pos.orders.add(reactiveNewOrder);
this.env.pos.selectedOrder = reactiveNewOrder;
}
this.showScreen('PaymentScreen');
}
/**
* @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];
const lineQty = line.get_quantity();
if(lineQty > 0) {
if (!line.get_unit().is_pos_groupable) {
if (split.quantity !== lineQty) {
split.quantity = lineQty;
} else {
split.quantity = 0;
}
} else {
if (split.quantity < lineQty) {
split.quantity += line.get_unit().is_pos_groupable? 1: line.get_unit().rounding;
if (split.quantity > lineQty) {
split.quantity = lineQty;
}
} 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.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);
}
}
}
}
}
SplitBillScreen.template = 'SplitBillScreen';
Registries.Component.add(SplitBillScreen);
return SplitBillScreen;
});

View file

@ -0,0 +1,25 @@
odoo.define('pos_restaurant.SplitOrderline', function(require) {
'use strict';
const { useListener } = require("@web/core/utils/hooks");
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class SplitOrderline extends PosComponent {
setup() {
super.setup();
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;
});

View file

@ -0,0 +1,196 @@
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/core/utils/hooks");
const { parse } = require('web.field_utils');
const { useState } = owl;
const PosResTicketScreen = (TicketScreen) =>
class extends TicketScreen {
close() {
if (!this.env.pos.config.iface_floorplan) {
super.close();
} else {
const order = this.env.pos.get_order();
if (order) {
const { name: screenName } = order.get_screen_data();
this.showScreen(screenName);
} else {
this.showScreen('FloorScreen');
}
}
}
_getScreenToStatusMap() {
return Object.assign(super._getScreenToStatusMap(), {
PaymentScreen: this.env.pos.config.set_tip_after_payment ? 'OPEN' : super._getScreenToStatusMap().PaymentScreen,
TipScreen: 'TIPPING',
});
}
getTable(order) {
const table = order.getTable();
return table ? `${table.floor.name} (${table.name})` : '';
}
//@override
_getSearchFields() {
if (!this.env.pos.config.iface_floorplan) {
return super._getSearchFields();
}
return Object.assign({}, super._getSearchFields(), {
TABLE: {
repr: this.getTable.bind(this),
displayName: this.env._t('Table'),
modelField: 'table_id.name',
}
});
}
async _setOrder(order) {
if (!this.env.pos.config.iface_floorplan || this.env.pos.table) {
super._setOrder(order);
} else {
// we came from the FloorScreen
const orderTable = order.getTable();
await this.env.pos.setTable(orderTable, order.uid);
this.close();
}
}
shouldShowNewOrderButton() {
return this.env.pos.config.iface_floorplan ? Boolean(this.env.pos.table) : super.shouldShowNewOrderButton();
}
_getOrderList() {
if (this.env.pos.table) {
return this.env.pos.getTableOrders(this.env.pos.table.id);
}
return super._getOrderList();
}
async settleTips() {
// set tip in each order
for (const order of this.getFilteredOrderList()) {
const tipAmount = parse.float(order.uiState.TipScreen.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;
}
}
}
//@override
_selectNextOrder(currentOrder) {
if (this.env.pos.config.iface_floorplan && this.env.pos.table) {
return super._selectNextOrder(...arguments);
}
}
//@override
async _onDeleteOrder() {
await super._onDeleteOrder(...arguments);
if (this.env.pos.config.iface_floorplan) {
if (!this.env.pos.table) {
this.env.pos._removeOrdersFromServer();
}
const orderList = this.env.pos.table ? this.env.pos.getTableOrders(this.env.pos.table.id) : this.env.pos.orders;
if (orderList.length == 0) {
this.showScreen('FloorScreen');
}
}
}
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(serverId);
} 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()],
});
}
if (order === this.env.pos.get_order()) {
this._selectNextOrder(order);
}
this.env.pos.removeOrder(order);
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(serverId) {
await this.rpc({
method: 'set_no_tip',
model: 'pos.order',
args: [serverId],
});
}
_getOrderStates() {
const result = super._getOrderStates();
if (this.env.pos.config.set_tip_after_payment) {
result.delete('PAYMENT');
result.set('OPEN', { text: this.env._t('Open'), indented: true });
result.set('TIPPING', { text: this.env._t('Tipping'), indented: true });
}
return result;
}
async _onDoRefund() {
const order = this.getSelectedSyncedOrder();
if(order && this.env.pos.config.iface_floorplan && !this.env.pos.table) {
this.env.pos.setTable(order.table ? order.table : Object.values(this.env.pos.tables_by_id)[0]);
}
super._onDoRefund();
}
isDefaultOrderEmpty(order) {
if (this.env.pos.config.iface_floorplan) {
return false;
}
return super.isDefaultOrderEmpty(...arguments);
}
};
Registries.Component.extend(TicketScreen, PosResTicketScreen);
class TipCell extends PosComponent {
setup() {
super.setup();
this.state = useState({ isEditing: false });
this.orderUiState = this.props.order.uiState.TipScreen;
useAutofocus();
}
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 };
});

View file

@ -0,0 +1,162 @@
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 { renderToString } = require('@web/core/utils/render');
const { onMounted } = owl;
class TipScreen extends PosComponent {
setup() {
super.setup();
this.state = this.currentOrder.uiState.TipScreen;
this._totalAmount = this.currentOrder.get_total_with_tax();
onMounted(() => {
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: this.env._t('Unsynced order'),
body: this.env._t('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.removeOrder(this.currentOrder);
if (!this.env.pos.config.iface_floorplan) {
this.env.pos.add_new_order();
}
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 = renderToString('TipReceipt', {
receipt: this.currentOrder.getOrderReceiptEnv().receipt,
data: data,
total: this.env.pos.format_currency(this.totalAmount),
});
if (this.env.proxy.printer) {
await this._printIoT(receipt);
} else {
await this._printWeb(receipt);
}
}
}
async _printIoT(receipt) {
const printResult = await this.env.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;
});

View file

@ -0,0 +1,651 @@
odoo.define('pos_restaurant.models', function (require) {
"use strict";
const { PosGlobalState, Order, Orderline, Payment } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
const { uuidv4 } = require('point_of_sale.utils');
const core = require('web.core');
const Printer = require('point_of_sale.Printer').Printer;
const { batched } = require('point_of_sale.utils')
const QWeb = core.qweb;
const TIMEOUT = 7500;
const PosRestaurantPosGlobalState = (PosGlobalState) => class PosRestaurantPosGlobalState extends PosGlobalState {
constructor(obj) {
super(obj);
this.orderToTransfer = null; // table transfer feature
this.ordersToUpdateSet = new Set(); // used to know which orders need to be sent to the back end when syncing
this.transferredOrdersSet = new Set(); // used to know which orders has been transferred but not sent to the back end yet
this.loadingOrderState = false; // used to prevent orders fetched to be put in the update set during the reactive change
}
//@override
async _processData(loadedData) {
await super._processData(...arguments);
if (this.config.is_table_management) {
this.floors = loadedData['restaurant.floor'];
this.loadRestaurantFloor();
}
if (this.config.module_pos_restaurant) {
this._loadRestaurantPrinter(loadedData['restaurant.printer']);
}
}
//@override
_onReactiveOrderUpdated(order) {
super._onReactiveOrderUpdated(...arguments)
if (this.config.iface_floorplan && !this.loadingOrderState) {
this.ordersToUpdateSet.add(order);
}
}
//@override
removeOrder(order, removeFromServer=true) {
super.removeOrder(...arguments);
if (this.config.iface_floorplan && removeFromServer) {
if (this.ordersToUpdateSet.has(order)) {
this.ordersToUpdateSet.delete(order)
}
if (order.server_id && !order.finalized) {
this.db.set_order_to_remove_from_server(order);
}
}
}
//@override
async after_load_server_data() {
var res = await super.after_load_server_data(...arguments);
if (this.config.iface_floorplan) {
this.table = null;
}
return res;
}
//@override
// 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() {
if (!this.config.iface_floorplan) {
super.set_start_order(...arguments);
}
}
//@override
add_new_order() {
const order = super.add_new_order();
this.ordersToUpdateSet.add(order);
return order;
}
//@override
createReactiveOrder(json) {
let reactiveOrder = super.createReactiveOrder(...arguments);
if (this.config.iface_printers) {
const updateOrderChanges = () => {
if (reactiveOrder.get_screen_data().name === 'ProductScreen') {
reactiveOrder.updateChangesToPrint();
}
}
reactiveOrder = owl.reactive(reactiveOrder, batched(updateOrderChanges));
reactiveOrder.updateChangesToPrint();
}
return reactiveOrder;
}
//@override
async load_orders() {
this.loadingOrderState = true;
await super.load_orders();
this.loadingOrderState = false;
}
_loadRestaurantPrinter(printers) {
this.unwatched.printers = [];
// list of product categories that belong to one or more order printer
this.printers_category_ids_set = new Set();
for (let printerConfig of printers) {
let printer = this.create_printer(printerConfig);
printer.config = printerConfig;
this.unwatched.printers.push(printer);
for (let id of printer.config.product_categories_ids) {
this.printers_category_ids_set.add(id);
}
}
this.config.iface_printers = !!this.unwatched.printers.length;
}
async _getTableOrdersFromServer(tableIds) {
this.set_synch('connecting', 1);
try {
const orders = await this.env.services.rpc({
model: 'pos.order',
method: 'get_table_draft_orders',
args: [tableIds],
}, {
timeout: TIMEOUT,
shadow: true,
});
this.set_synch('connected');
return orders;
} catch (error) {
this.set_synch('error');
throw error;
}
}
/**
* Sync orders that got updated to the back end
* @param tableId ID of the table we want to sync
*/
async _syncTableOrdersToServer() {
await this._pushOrdersToServer();
await this._removeOrdersFromServer();
// This need to be called here otherwise _onReactiveOrderUpdated() will be called after the set is being cleared
this.ordersToUpdateSet.clear();
this.transferredOrdersSet.clear();
}
/**
* Send the orders to be saved to the back end
* @throw error
*/
async _pushOrdersToServer() {
const ordersUidsToSync = [...this.ordersToUpdateSet].map(order => order.uid);
const ordersToSync = this.db.get_unpaid_orders_to_sync(ordersUidsToSync);
const ordersResponse = await this._save_to_server(ordersToSync, {'draft': true});
const tableOrders = [...this.ordersToUpdateSet].map(order => order);
ordersResponse.forEach(orderResponseData => this._updateTableOrder(orderResponseData, tableOrders));
}
// created this hook for modularity
_updateTableOrder(ordersResponseData, tableOrders) {
const order = tableOrders.find(order => order.name === ordersResponseData.pos_reference);
order.server_id = ordersResponseData.id;
return order;
}
/**
* Remove the deleted orders from the backend.
* @throw error
*/
async _removeOrdersFromServer() {
const removedOrdersIds = this.db.get_ids_to_remove_from_server();
if (removedOrdersIds.length === 0) {
return;
}
const timeout = TIMEOUT * removedOrdersIds.length;
this.set_synch('connecting', removedOrdersIds.length);
try {
const removeOrdersResponseData = await this.env.services.rpc({
model: 'pos.order',
method: 'remove_from_ui',
args: [removedOrdersIds],
}, {
timeout: timeout,
shadow: true,
});
this.set_synch('connected');
this._postRemoveFromServer(removedOrdersIds, removeOrdersResponseData);
} catch (reason) {
let error = reason.message;
if (error.code === 200) {
// Business Logic Error, not a connection problem
//if warning do not need to display traceback!!
if (error.data.exception_type == 'warning') {
delete error.data.debug;
}
}
// important to throw error here and let the rendering component handle the error
console.warn('Failed to remove orders:', removedOrdersIds);
throw error;
}
}
// to override
_postRemoveFromServer(serverIds, data) {
this.db.set_ids_removed_from_server(serverIds);
}
/**
* Replace all the orders of a table by orders fetched from the backend
* @param tableId ID of the table
* @throws error
*/
async _syncTableOrdersFromServer(tableId) {
await this._removeOrdersFromServer(); // in case we were offline and we deleted orders in the mean time
const ordersJsons = await this._getTableOrdersFromServer([tableId]);
const tableOrders = this.getTableOrders(tableId);
this._replaceOrders(tableOrders, ordersJsons);
}
async _syncAllOrdersFromServer() {
await this._removeOrdersFromServer(); // in case we were offline and we deleted orders in the mean time
const tableIds = [].concat(...this.floors.map(floor => floor.tables.map(table => table.id)));
const ordersJsons = await this._getTableOrdersFromServer(tableIds); // get all orders
await this._syncTableOrdersToServer(); // to prevent losing the transferred orders
const allOrders = [...this.get_order_list()];
this._replaceOrders(allOrders, ordersJsons);
}
_replaceOrders(ordersToReplace, newOrdersJsons) {
ordersToReplace.forEach(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 && !this.transferredOrdersSet.has(order)){
this.removeOrder(order, false);
}
});
newOrdersJsons.forEach(json => {
// Because of the offline feature, some draft orders fetched from the backend will appear
// to belong in different table, but in fact they are already moved.
const transferredOrder = [...this.transferredOrdersSet].find(order => order.uid === json.uid)
const isSameTable = transferredOrder && transferredOrder.tableId === json.tableId;
if (isSameTable) {
// this means we transferred back to the original table, we'll prioritize the server state
this.removeOrder(transferredOrder, false);
}
if (!transferredOrder || isSameTable) {
const order = this.createReactiveOrder(json);
this.orders.add(order);
}
});
}
setLoadingOrderState(bool) {
this.loadingOrderState = bool;
}
loadRestaurantFloor() {
// we do this in the front end due to the circular/recursive reference needed
// Ignore floorplan features if no floor specified.
this.config.iface_floorplan = !!(this.floors && this.floors.length > 0);
if (this.config.iface_floorplan) {
this.floors_by_id = {};
this.tables_by_id = {};
for (let floor of this.floors) {
this.floors_by_id[floor.id] = floor;
for (let table of floor.tables) {
this.tables_by_id[table.id] = table;
table.floor = floor;
}
}
}
}
async setTable(table, orderUid=null) {
this.table = table;
try {
this.loadingOrderState = true;
await this._syncTableOrdersFromServer(table.id);
} catch (error) {
throw error;
} finally {
this.loadingOrderState = false;
const currentOrder = this.getTableOrders(table.id).find(order => orderUid ? order.uid === orderUid : !order.finalized);
if (currentOrder) {
this.set_order(currentOrder);
} else {
this.add_new_order();
}
}
}
getTableOrders(tableId) {
return this.get_order_list().filter(order => order.tableId === tableId);
}
unsetTable() {
this._syncTableOrdersToServer();
this.table = null;
this.set_order(null);
}
setCurrentOrderToTransfer() {
this.orderToTransfer = this.selectedOrder;
}
async transferTable(table) {
this.table = table;
try {
this.loadingOrderState = true;
await this._syncTableOrdersFromServer(table.id);
} catch (error) {
throw error;
} finally {
this.loadingOrderState = false;
this.orderToTransfer.tableId = table.id;
this.set_order(this.orderToTransfer);
this.transferredOrdersSet.add(this.orderToTransfer);
this.orderToTransfer = null;
}
}
getCustomerCount(tableId) {
const tableOrders = this.getTableOrders(tableId).filter(order => !order.finalized);
return tableOrders.reduce((count, order) => count + order.getCustomerCount(), 0);
}
create_printer(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);
}
}
Registries.Model.extend(PosGlobalState, PosRestaurantPosGlobalState);
// New orders are now associated with the current table, if any.
const PosRestaurantOrder = (Order) => class PosRestaurantOrder extends Order {
constructor(obj, options) {
super(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.pos.config.iface_floorplan && !this.tableId && !options.json) {
this.tableId = this.pos.table.id;
}
this.customerCount = this.customerCount || 1;
}
if (this.pos.config.iface_printers) {
// printedResume will store the previous state of the orderlines (when there were no skip), it will
// store all the orderlines even if the product are not printable. This way, when we add a new category in
// the printers, the already added products of the newly added category are not printed.
this.printedResume = owl.markRaw(this.printedResume || {}); // we don't wanna track it and re-render
// no need to store this in the backend, we can just compute it once the order is fetched from clicking a table
if (!this.printingChanges) {
this._resetPrintingChanges();
}
}
}
//@override
export_as_JSON() {
const json = super.export_as_JSON(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.pos.config.iface_floorplan) {
json.table_id = this.tableId
}
json.customer_count = this.customerCount;
}
if (this.pos.config.iface_printers) {
json.multiprint_resume = JSON.stringify(this.printedResume);
// so that it can be stored in local storage and be used when loading the pos in the floorscreen
json.printing_changes = JSON.stringify(this.printingChanges);
}
return json;
}
//@override
init_from_JSON(json) {
super.init_from_JSON(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.pos.config.iface_floorplan) {
this.tableId = json.table_id;
this.validation_date = moment.utc(json.creation_date).local().toDate();
}
this.customerCount = json.customer_count;
}
if (this.pos.config.iface_printers) {
this.printedResume = json.multiprint_resume && JSON.parse(json.multiprint_resume);
this.printingChanges = json.printing_changes && JSON.parse(json.printing_changes);
}
}
//@override
export_for_printing() {
const json = super.export_for_printing(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.pos.config.iface_floorplan) {
json.table = this.getTable().name;
}
json.customer_count = this.getCustomerCount();
}
return json;
}
_resetPrintingChanges() {
this.printingChanges = { new:[], cancelled:[] };
}
/**
* @returns {{ [productKey: string]: { product_id: number, name: string, note: string, quantity: number } }}
*/
_computePrintChanges() {
const changes = {};
// If there's a new orderline, we add it otherwise we add the change if there's one
this.orderlines.forEach(line => {
if (!line.mp_skip) {
const productId = line.get_product().id;
const note = line.get_note();
const productKey = `${productId} - ${line.get_full_product_name()} - ${note}`;
const lineKey = `${line.uuid} - ${note}`;
const quantityDiff = line.get_quantity() - (this.printedResume[lineKey] ? this.printedResume[lineKey]['quantity'] : 0);
if (quantityDiff) {
if (!changes[productKey]) {
changes[productKey] = {
product_id: productId,
name: line.get_full_product_name(),
note: note,
quantity: quantityDiff,
}
} else {
changes[productKey]['quantity'] += quantityDiff;
}
line.set_dirty(true);
} else {
line.set_dirty(false);
}
}
})
// If there's an orderline that's not present anymore, we consider it as removed (even if note changed)
for (const [lineKey, lineResume] of Object.entries(this.printedResume)) {
if (!this._getPrintedLine(lineKey)) {
const productKey = `${lineResume['product_id']} - ${lineResume['name']} - ${lineResume['note']}`;
if (!changes[productKey]) {
changes[productKey] = {
product_id: lineResume['product_id'],
name: lineResume['name'],
note: lineResume['note'],
quantity: -lineResume['quantity'],
}
} else {
changes[productKey]['quantity'] -= lineResume['quantity'];
}
}
}
return changes;
}
_getPrintingCategoriesChanges(categories) {
return {
new: this.printingChanges['new'].filter(change => this.pos.db.is_product_in_category(categories, change['product_id'])),
cancelled: this.printingChanges['cancelled'].filter(change => this.pos.db.is_product_in_category(categories, change['product_id'])),
}
}
_getPrintedLine(lineKey) {
return this.orderlines.find(line => line.uuid === this.printedResume[lineKey]['line_uuid'] &&
line.note === this.printedResume[lineKey]['note']);
}
getCustomerCount(){
return this.customerCount;
}
setCustomerCount(count) {
this.customerCount = Math.max(count,0);
}
getTable() {
if (this.pos.config.iface_floorplan) {
return this.pos.tables_by_id[this.tableId];
}
return null;
}
updatePrintedResume(){
// we first remove the removed orderlines
for (const lineKey in this.printedResume) {
if (!this._getPrintedLine(lineKey)) {
delete this.printedResume[lineKey];
}
}
// we then update the added orderline or product quantity change
this.orderlines.forEach(line => {
if (!line.mp_skip) {
const note = line.get_note();
const lineKey = `${line.uuid} - ${note}`;
if (this.printedResume[lineKey]) {
this.printedResume[lineKey]['quantity'] = line.get_quantity();
} else {
this.printedResume[lineKey] = {
line_uuid: line.uuid,
product_id: line.get_product().id,
name: line.get_full_product_name(),
note: note,
quantity: line.get_quantity()
}
}
line.set_dirty(false);
}
});
this._resetPrintingChanges();
}
updateChangesToPrint() {
const changes = this._computePrintChanges(); // it's possible to have a change's quantity of 0
// we thoroughly parse the changes we just computed to properly separate them into two
const toAdd = [];
const toRemove = [];
for (const lineChange of Object.values(changes)) {
if (lineChange['quantity'] > 0) {
toAdd.push(lineChange);
} else if (lineChange['quantity'] < 0) {
lineChange['quantity'] *= -1; // we change the sign because that's how it is
toRemove.push(lineChange);
}
}
this.printingChanges = { new: toAdd, cancelled: toRemove };
}
hasChangesToPrint(){
for (const printer of this.pos.unwatched.printers) {
const changes = this._getPrintingCategoriesChanges(printer.config.product_categories_ids);
if (changes['new'].length > 0 || changes['cancelled'].length > 0) {
return true;
}
}
return false;
}
hasSkippedChanges() {
var orderlines = this.get_orderlines();
for (var i = 0; i < orderlines.length; i++) {
if (orderlines[i].mp_skip) {
return true;
}
}
return false;
}
async printChanges(){
let isPrintSuccessful = true;
const d = new Date();
let hours = '' + d.getHours();
hours = hours.length < 2 ? ('0' + hours) : hours;
let minutes = '' + d.getMinutes();
minutes = minutes.length < 2 ? ('0' + minutes) : minutes;
for (const printer of this.pos.unwatched.printers) {
const changes = this._getPrintingCategoriesChanges(printer.config.product_categories_ids);
if (changes['new'].length > 0 || changes['cancelled'].length > 0) {
const printingChanges = {
new: changes['new'],
cancelled: changes['cancelled'],
table_name: this.pos.config.iface_floorplan ? this.getTable().name : false,
floor_name: this.pos.config.iface_floorplan ? this.getTable().floor.name : false,
name: this.name || 'unknown order',
time: {
hours,
minutes,
},
};
const receipt = QWeb.render('OrderChangeReceipt', { changes: printingChanges });
const result = await printer.print_receipt(receipt);
if (!result.successful) {
isPrintSuccessful = false;
}
}
}
return isPrintSuccessful;
}
}
Registries.Model.extend(Order, PosRestaurantOrder);
const PosRestaurantOrderline = (Orderline) => class PosRestaurantOrderline extends Orderline {
constructor() {
super(...arguments);
this.note = this.note || "";
if (this.pos.config.iface_printers) {
this.uuid = this.uuid || uuidv4();
// mp dirty is true if this orderline has changed since the last kitchen print
this.mp_dirty = false
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;
}
}
}
//@override
can_be_merged_with(orderline) {
if (orderline.get_note() !== this.get_note()) {
return false;
} else {
return (!this.mp_skip) && (!orderline.mp_skip) && super.can_be_merged_with(...arguments);
}
}
//@override
clone(){
const orderline = super.clone(...arguments);
orderline.note = this.note;
return orderline;
}
//@override
export_as_JSON(){
const json = super.export_as_JSON(...arguments);
json.note = this.note;
if (this.pos.config.iface_printers) {
json.uuid = this.uuid;
json.mp_skip = this.mp_skip;
}
return json;
}
//@override
init_from_JSON(json){
super.init_from_JSON(...arguments);
this.note = json.note;
if (this.pos.config.iface_printers) {
this.uuid = json.uuid;
this.mp_skip = json.mp_skip;
}
}
set_note(note){
this.note = note;
}
get_note(){
return this.note;
}
set_skip(skip) {
if (this.mp_dirty && skip && !this.mp_skip) {
this.mp_skip = true;
}
if (this.mp_skip && !skip) {
this.mp_skip = false;
}
}
set_dirty(dirty) {
if (this.printable()) {
this.mp_dirty = dirty;
}
}
get_line_diff_hash(){
if (this.get_note()) {
return this.id + '|' + this.get_note();
} else {
return '' + this.id;
}
}
// can this orderline be potentially printed ?
printable() {
return this.pos.db.is_product_in_category(this.pos.printers_category_ids_set, this.get_product().id);
}
}
Registries.Model.extend(Orderline, PosRestaurantOrderline);
const PosRestaurantPayment = (Payment) => class PosRestaurantPayment extends Payment {
/**
* 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() {
if (this.payment_method.payment_terminal) {
return this.payment_method.payment_terminal.canBeAdjusted(this.cid);
}
return !this.payment_method.is_cash_count;
}
}
Registries.Model.extend(Payment, PosRestaurantPayment);
});

View file

@ -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) {},
});
});

View file

@ -0,0 +1,586 @@
/* --- Restaurant Specific CSS --- */
.screen .screen-content-flexbox {
margin: 0px auto;
text-align: left;
height: 100%;
overflow: hidden;
position: relative;
display: -webkit-flex;
-webkit-flex-flow: column nowrap;
flex-flow: column nowrap;
}
/* ------ FLOOR SELECTOR ------- */
.floor-selector {
line-height: 48px;
font-size: 18px;
display: -webkit-flex;
display: flex;
text-align: center;
width: 100%;
}
.floor-selector .button {
cursor: pointer;
border-left: solid 1px $gray-400;
-webkit-flex: 1;
flex: 1;
}
.floor-selector .button:first-child {
border-left: none;
}
.floor-selector .button.active {
background: $o-navbar-badge-bg;
color: white;
}
/* ------ FLOOR MAP ------- */
.floor-map {
-webkit-flex: 1;
flex: 1;
position: relative;
width: auto;
height: 100%;
box-shadow: 0px 6px 0px -3px rgba(0,0,0,0.07) inset;
background: #D8D7D7;
background-repeat: no-repeat;
overflow: auto;
background-size: cover;
transition: all 300ms ease-in-out;
}
.floor-map .tables {
position: relative;
--scale: 1;
transform: scale(var(--scale));
}
@media screen and (min-width: 1024px) {
.floor-map .tables {
max-width: 1024px;
margin: auto;
max-height: 540px;
border-radius: 0px 0px 6px 6px;
border: dashed 2px rgba(0,0,0,0.1);
border-top: none;
height: 100%;
}
}
.floor-map .table{
position: absolute;
text-align: center;
font-size: 18px;
color: white;
background: rgb(53, 211, 116);
border-radius: 3px;
cursor: pointer;
box-shadow: 0px 3px rgba(0,0,0,0.07);
transition: background, background-color 300ms ease-in-out;
overflow: hidden;
}
.floor-map .table .table-cover {
display: block;
position: absolute;
left: 0; right: 0; bottom: 0;
border-radius: 0px 0px 3px 3px;
background: rgba(0,0,0,0.2);
}
.floor-map .table .table-cover.full {
border-radius: 3px 3px 3px 3px;
}
.floor-map .table .table-seats {
position: absolute;
display: inline-block;
bottom: 0;
left: 50%;
height: 20px;
line-height: 20px;
font-size: 16px;
border-radius: 5px;
margin-left: -16px;
margin-bottom: 4px;
background: black;
color: white;
opacity: 0.2;
z-index: 3;
padding: 0 4px;
}
.floor-map .table .label {
display: block;
max-height: 100%;
overflow: hidden;
position: relative;
bottom: 5px;
}
.floor-map .table.selected {
outline: solid rgba(255,255,255,0.3);
cursor: move;
z-index: 50;
}
.floor-map .table.selected .table-seats {
margin-left: -12px;
}
.floor-map .edit-button.editing {
position: fixed;
top: 0;
right: 0;
font-size: 20px;
margin: 8px;
line-height: 32px;
width: 32px;
text-align: center;
border-radius: 5px;
cursor: pointer;
border: solid 1px rgba(0,0,0,0.2);
}
.floor-map .edit-button.editing.active {
background: #444;
border-color: transparent;
color: white;
}
.floor-map .edit-bar {
position: fixed;
top: 0;
right: 40px;
margin: 8px;
line-height: 34px;
text-align: center;
border-radius: 5px;
cursor: pointer;
font-size: 20px;
background: rgba(255,255,255,0.5);
z-index: 100;
}
.floor-map .edit-bar .edit-button {
position: relative;
width: 32px;
display: inline-block;
cursor: pointer;
margin-right: -4px;
border-right: solid 1px rgba(0,0,0,0.2);
transition: all 150ms linear;
}
.floor-map .edit-bar .edit-button.disabled {
cursor: default;
}
.floor-map .edit-bar .edit-button.disabled > * {
opacity: 0.5;
}
.floor-map .edit-bar .color-picker {
position: absolute;
left: 36px;
top: 40px;
width: 180px;
height: 180px;
border-radius: 3px;
z-index: 100;
}
.floor-map .edit-bar .color-picker .color {
display: block;
float: left;
cursor: pointer;
width: 60px;
height: 60px;
background-color: gray;
}
.floor-map .edit-bar .color-picker .color.tl { border-top-left-radius: 3px; }
.floor-map .edit-bar .color-picker .color.tr { border-top-right-radius: 3px; }
.floor-map .edit-bar .color-picker .color.bl { border-bottom-left-radius: 3px; }
.floor-map .edit-bar .color-picker .color.br { border-bottom-right-radius: 3px; }
.floor-map .edit-bar .close-picker {
position: absolute;
bottom: 0;
left: 50%;
margin-left: -16px;
margin-bottom: -16px;
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
font-size: 20px;
border-radius: 16px;
background: black;
color: white;
cursor: pointer;
}
.floor-map .edit-bar .edit-button:last-child {
margin-right: 0;
border-right: none;
}
.floor-map .table.selected .table-handle {
padding: 0px;
position: absolute;
width: 48px;
height: 48px;
left: 50%;
top: 50%;
border-radius: 24px;
background: white;
box-shadow: 0px 2px 3px rgba(0,0,0,0.2);
/* See o-grab-cursor mixin */
cursor: url(/web/static/img/openhand.cur), grab;
transition: all 150ms linear;
z-index: 100;
transform: translate(-50%, -50%);
}
.floor-map .table.selected .table-handle:hover {
width: 60px;
height: 60px;
border-radius: 30px;
}
.floor-map .table .table-handle.top { top: 0; }
.floor-map .table .table-handle.bottom { top: 100%; }
.floor-map .table .table-handle.left { left: 0; }
.floor-map .table .table-handle.right { left: 100%; }
.floor-map .table .order-count {
position: absolute;
top: 0;
left: 50%;
background: black;
width: 20px;
margin-top: 1px;
margin-left: -10px;
height: 20px;
line-height: 20px;
border-radius: 10px;
font-size: 16px;
z-index: 10;
}
.floor-map .table .order-count.notify-printing {
background: red;
}
.floor-map .table .order-count.notify-skipped {
background: blue;
}
.floor-map .empty-floor {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: 400px;
height: 40px;
font-size: 18px;
text-align: center;
opacity: 0.6;
}
.floor-map .empty-floor i {
display: inline-block;
padding: 6px 7px 3px;
margin: 0px 3px;
background: rgba(255,255,255,0.5);
border-radius: 3px;
}
/* ------ FLOOR BUTTON IN THE ORDER SELECTOR ------- */
.pos .order-button.floor-button {
display: flex;
align-items: center;
background: $o-navbar-badge-bg;
font-weight: bold;
font-size: 16px;
padding-left: 16px;
padding-right: 16px;
overflow-wrap: anywhere;
text-overflow: hidden;
}
.pos .order-button.floor-button .table-name {
margin-left: 5px;
}
.pos .order-button.floor-button .fa{
font-size: 24px;
line-height: 42px;
}
/* ------ ORDER LINE STATUS ------- */
.pos .order .orderline.dirty {
border-left: solid 6px #6EC89B;
color: #6EC89B;
padding-left: 9px;
}
.pos .order .orderline.skip {
border-left: solid 6px #7F82AC;
color: #7F82AC;
padding-left: 9px;
}
/* ------ SPLIT BILL SCREEN ------- */
.splitbill-screen.screen .contents {
display: flex;
flex-flow: column nowrap;
margin: 0px auto;
max-width: 1024px;
border-left: dashed 1px rgb(215,215,215);
border-right: dashed 1px rgb(215,215,215);
height: 100%;
}
.splitbill-screen.screen .main {
display: flex;
flex-flow: row nowrap;
/* take the remaining vertical space */
flex: 1;
/* do not capture overflow in this element */
overflow: hidden;
}
.splitbill-screen.screen .main .lines {
display: flex;
flex: 1;
justify-content: center;
/* show scrollbar inside this element if its content overflows */
overflow-y: auto;
}
.splitbill-screen.screen .main .controls {
display: flex;
flex-flow: column nowrap;
}
.splitbill-screen.screen .pay-button {
margin: 16px;
}
.splitbill-screen.screen .pay-button .button {
background: $gray-200;
line-height: 74px;
font-size: 16px;
border: solid 1px rgb(202, 202, 202);
border-top-width: 0px;
cursor: pointer;
width: 100%;
text-align: center;
}
.splitbill-screen.screen .pay-button .button:first-child {
border-top-width: 1px;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.splitbill-screen.screen .pay-button .button:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
.splitbill-screen.screen .pay-button .button:active {
background: black;
border-color: black;
color: white;
}
.splitbill-screen.screen .main .lines {
border-right: dashed 1px rgb(215,215,215);
}
.splitbill-screen.screen .main .controls {
flex: 1;
}
/* ------ NARROWER SCREEN ------ */
@media screen and (max-width: 768px) {
.splitbill-screen.screen .main {
flex-flow: column nowrap;
}
.splitbill-screen.screen .main .controls {
border-top: dashed 1px rgb(215,215,215);
flex: none;
}
}
.tip-screen .tip-options .total-amount {
text-align: center;
font-size: x-large;
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.tip-screen .tip-amount-options {
background: white;
border-radius: 3px;
margin-left: 4rem;
margin-right: 4rem;
}
.tip-screen .percentage-amounts {
display: flex;
}
.tip-screen .tip-options .button {
background: #FAFAFA;
border: solid 1px rgb(209, 209, 209);
border-radius: 3px;
flex: 1;
margin: 1rem;
display: flex;
flex-flow: column nowrap;
justify-content: center;
cursor: pointer;
}
.tip-screen .tip-options .button .percentage {
text-align: center;
font-size: xx-large;
color: #2196F3;
padding-top: 4rem;
padding-bottom: 0.75rem;
}
.tip-screen .tip-options .button .amount {
text-align: center;
font-size: large;
color: #757575;
padding-bottom: 4rem;
}
.tip-screen .custom-amount-form {
display: flex;
}
.tip-screen .custom-amount-form .item {
font-size: x-large;
color: #2196F3;
margin: 1rem;
flex: 1
}
.tip-screen .custom-amount-form .label {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
text-align: center;
}
.tip-screen .custom-amount-form .input {
background: #FAFAFA;
border: solid 1px rgb(209, 209, 209);
border-radius: 3px;
margin: 1rem;
text-align: right;
overflow: hidden;
position: relative;
}
.tip-screen .custom-amount-form .input .currency {
position: absolute;
display: flex;
right: 0;
top: 0;
bottom: 0;
width: 3rem;
justify-content: center;
align-items: center;
}
.tip-screen .custom-amount-form .input input {
border: none;
padding: 0;
outline: none;
font-size: x-large;
text-align: right;
margin-left: 1rem;
margin-right: 3rem;
height: 100%;
width: calc(100% - 4rem);
background: #FAFAFA;
color: #2196F3;
}
.tip-screen .custom-amount-form .input input:focus {
border: none;
padding: 0;
outline: none;
}
.tip-screen .custom-amount-form .add {
text-align: center;
}
.tip-screen .no-tip {
display: flex;
color: #2196F3;
font-size: x-large;
}
.tip-screen .no-tip .button {
text-align: center;
flex: 1;
padding: 1.5rem;
}
.tip-screen .pos-receipt-container {
display: none;
position: fixed;
top: 0;
left: 0;
margin: 0;
}
@media print {
.tip-screen .pos-receipt-container {
display: block;
}
.tip-screen .pos-receipt-container * {
visibility: visible;
}
}
.pos-receipt .tip-form {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.pos-receipt .tip-form .title {
text-align: center;
}
.pos-receipt .tip-form > div {
margin-top: 1rem;
}
.pos-receipt .tip-form .option {
display: flex;
flex-flow: column nowrap;
align-items: center;
flex: 1;
justify-content: center;
}
.pos-receipt .tip-form .percentage-options {
display: flex;
flex-flow: row nowrap;
}
.ticket-screen .tip-cell {
height: 100%;
width: 100%;
text-align: right;
white-space: nowrap;
}
.ticket-screen .tip-cell input {
width: 100%;
text-align: right;
font-size: medium;
color: #555555;
padding-bottom: 5px;
border: none;
outline: none;
}
.ticket-screen .tip-cell input:focus {
border: none;
outline: none;
border-bottom: solid 1px #555555;
}
.multiprint-flex {
display: flex;
margin-bottom: 10px;
}
.multiprint-flex .product-quantity {
margin-right: 50px;
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Chrome" t-inherit="point_of_sale.Chrome" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('status-buttons')]" position="before">
<div class="back-to-floor-portal"/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="BackToFloorButton" owl="1">
<span
t-if="hasTable"
class="order-button floor-button"
t-att-class="{ oe_hidden: props.mobileSearchBarIsShown }"
t-on-click="backToFloorScreen"
>
<i class="fa fa-angle-double-left" role="img" aria-label="Back to floor" title="Back to floor" />
<t t-if="env.isMobile">
<span class="table-name" t-esc="table.name"/>
</t>
<t t-else="">
<span t-esc="floor.name" /><span class="table-name">(<t t-esc="table.name" />)</span>
</t>
</span>
<span t-else=""></span>
</t>
</templates>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Resizeable" owl="1">
<t t-slot="default"></t>
</t>
</templates>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="BillScreen" owl="1">
<div class="receipt-screen screen">
<div class="screen-content">
<div class="top-content">
<div class="top-content-center">
<h1>Bill Printing</h1>
</div>
<span class="button next highlight" t-on-click="confirm">
<span>Ok</span>
<span> </span>
<i class="fa fa-angle-double-right"></i>
</span>
</div>
<div class="centered-content">
<div class="button print" t-on-click="printReceipt">
<i class="fa fa-print"></i>
<span> </span>
<span>Print</span>
</div>
<div class="pos-receipt-container" t-ref="order-receipt">
<OrderReceipt order="currentOrder" isBill="true"/>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="EditBar" owl="1">
<div class="edit-bar" t-attf-style="top:{{props.floorMapScrollTop}}px;">
<span class="edit-button" t-on-click.stop="props.createTable">
<i class="fa fa-plus" role="img" aria-label="Add" title="Add"></i>
</span>
<span class="edit-button" t-att-class="{ disabled: !props.selectedTable }" t-on-click.stop="props.duplicateTable">
<i class="fa fa-files-o" role="img" aria-label="Duplicate" title="Duplicate"></i>
</span>
<span class="edit-button" t-att-class="{ disabled: !props.selectedTable }" t-on-click.stop="props.renameTable">
<i class="fa fa-font" role="img" aria-label="Rename" title="Rename"></i>
</span>
<span class="edit-button" t-att-class="{ disabled: !props.selectedTable }" t-on-click.stop="props.changeSeatsNum">
<i class="fa fa-user" role="img" aria-label="Seats" title="Seats"></i>
</span>
<span class="edit-button" t-att-class="{ disabled: !props.selectedTable }" t-on-click.stop="props.changeShape">
<span t-if="!props.selectedTable or props.selectedTable.shape == 'square'" class="button-option square">
<i class="fa fa-square-o" role="img" aria-label="Square Shape" title="Square Shape"></i>
</span>
<span t-else="" class="button-option round">
<i class="fa fa-circle-o" role="img" aria-label="Round Shape" title="Round Shape"></i>
</span>
</span>
<span class="edit-button" t-on-click.stop="() => { state.isColorPicker = !state.isColorPicker }">
<i class="fa fa-tint" role="img" aria-label="Tint" title="Tint"></i>
</span>
<div t-if="state.isColorPicker and props.selectedTable" class="color-picker fg-picker">
<div class="close-picker" title="Close" role="img" aria-label="Close" t-on-click.stop="() => { state.isColorPicker = false; }">
<i class="fa fa-times" />
</div>
<span class="color tl" style="background-color:#EB6D6D" role="img" aria-label="Red" title="Red" t-on-click.stop="() => props.setTableColor('#EB6D6D')" />
<span class="color" style="background-color:#35D374" role="img" aria-label="Green" title="Green" t-on-click.stop="() => props.setTableColor('#35D374')" />
<span class="color tr" style="background-color:#6C6DEC" role="img" aria-label="Blue" title="Blue" t-on-click.stop="() => props.setTableColor('#6C6DEC')" />
<span class="color" style="background-color:#EBBF6D" role="img" aria-label="Orange" title="Orange" t-on-click.stop="() => props.setTableColor('#EBBF6D')" />
<span class="color" style="background-color:#EBEC6D" role="img" aria-label="Yellow" title="Yellow" t-on-click.stop="() => props.setTableColor('#EBEC6D')" />
<span class="color" style="background-color:#AC6DAD" role="img" aria-label="Purple" title="Purple" t-on-click.stop="() => props.setTableColor('#AC6DAD')" />
<span class="color bl" style="background-color:#6C6D6D" role="img" aria-label="Grey" title="Grey" t-on-click.stop="() => props.setTableColor('#6C6D6D')" />
<span class="color" style="background-color:#ACADAD" role="img" aria-label="Light grey" title="Light grey" t-on-click.stop="() => props.setTableColor('#ACADAD')" />
<span class="color br" style="background-color:#4ED2BE" role="img" aria-label="Turquoise" title="Turquoise" t-on-click.stop="() => props.setTableColor('#4ED2BE')" />
</div>
<div t-if="state.isColorPicker and !props.selectedTable" class="color-picker bg-picker">
<div class="close-picker" title="Close" role="img" aria-label="Close" t-on-click.stop="() => { state.isColorPicker = false; }">
<i class="fa fa-times" />
</div>
<span class="color tl" style="background-color:rgb(244, 149, 149)" role="img" aria-label="Red" title="Red" t-on-click.stop="() => props.setFloorColor('rgb(244, 149, 149)')" />
<span class="color" style="background-color:rgb(130, 233, 171)" role="img" aria-label="Green" title="Green" t-on-click.stop="() => props.setFloorColor('rgb(130, 233, 171)')" />
<span class="color tr" style="background-color:rgb(136, 137, 242)" role="img" aria-label="Blue" title="Blue" t-on-click.stop="() => props.setFloorColor('rgb(136, 137, 242)')" />
<span class="color" style="background-color:rgb(255, 214, 136)" role="img" aria-label="Orange" title="Orange" t-on-click.stop="() => props.setFloorColor('rgb(255, 214, 136)')" />
<span class="color" style="background-color:rgb(254, 255, 154)" role="img" aria-label="Yellow" title="Yellow" t-on-click.stop="() => props.setFloorColor('rgb(254, 255, 154)')" />
<span class="color" style="background-color:rgb(209, 171, 210)" role="img" aria-label="Purple" title="Purple" t-on-click.stop="() => props.setFloorColor('rgb(209, 171, 210)')" />
<span class="color bl" style="background-color:rgb(75, 75, 75)" role="img" aria-label="Grey" title="Grey" t-on-click.stop="() => props.setFloorColor('rgb(75, 75, 75)')" />
<span class="color" style="background-color:rgb(210, 210, 210)" role="img" aria-label="Light grey" title="Light grey" t-on-click.stop="() => props.setFloorColor('rgb(210, 210, 210)')" />
<span class="color br" style="background-color:rgb(127, 221, 236)" role="img" aria-label="Turquoise" title="Turquoise" t-on-click.stop="() => props.setFloorColor('rgb(127, 221, 236)')" />
</div>
<span class="edit-button trash" t-att-class="{ disabled: !props.selectedTable }" t-on-click.stop="props.deleteTable">
<i class="fa fa-trash" role="img" aria-label="Delete" title="Delete"></i>
</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="EditableTable" owl="1">
<Draggable limitArea="'.floor-map'">
<Resizeable limitArea="'.floor-map'">
<div class="table selected" t-on-click.stop="">
<span class="label drag-handle">
<t t-esc="props.table.name" />
</span>
<span class="table-seats">
<t t-esc="props.table.seats" />
</span>
<t t-if="props.table.shape === 'round'">
<div class="table-handle top resize-handle-n"></div>
<div class="table-handle bottom resize-handle-s"></div>
<div class="table-handle left resize-handle-w"></div>
<div class="table-handle right resize-handle-e"></div>
</t>
<t t-if="props.table.shape === 'square'">
<span class='table-handle top right resize-handle-ne'></span>
<span class='table-handle top left resize-handle-nw'></span>
<span class='table-handle bottom right resize-handle-se'></span>
<span class='table-handle bottom left resize-handle-sw'></span>
</t>
</div>
</Resizeable>
</Draggable>
</t>
</templates>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="FloorScreen" owl="1">
<div class="floor-screen screen">
<div class="screen-content-flexbox">
<t t-if="env.pos.floors.length > 1">
<div class="floor-selector">
<t t-foreach="env.pos.floors" t-as="floor" t-key="floor.id">
<span class="button button-floor" t-att-class="{ active: floor.id === state.selectedFloorId }" t-on-click="() => this.selectFloor(floor)">
<t t-esc="floor.name" />
</span>
</t>
</div>
</t>
<div
t-on-click="_onDeselectTable"
t-on-touchstart="_onPinchStart"
t-on-touchmove="_onPinchMove"
t-on-touchend="_onPinchEnd"
class="floor-map"
t-ref="floor-map-ref"
>
<div t-if="isFloorEmpty" class="empty-floor">
<span>This floor has no tables yet, use the </span>
<i class="fa fa-plus" role="img" aria-label="Add button" title="Add button"></i>
<span> button in the editing toolbar to create new tables.</span>
</div>
<div t-else="" class="tables">
<t t-foreach="activeTables" t-as="table" t-key="table.id">
<TableWidget t-if="table.id !== state.selectedTableId" onClick.bind="onSelectTable" table="table" />
<EditableTable t-else="" table="table" onSaveTable.bind="onSaveTable" />
</t>
</div>
<span t-if="env.pos.user.role == 'manager'" class="edit-button editing" t-att-class="{ active: state.isEditMode }" t-on-click.stop="toggleEditMode"
t-attf-style="top:{{state.floorMapScrollTop}}px;">
<i class="fa fa-pencil" role="img" aria-label="Edit" title="Edit"></i>
</span>
<EditBar t-if="state.isEditMode" selectedTable="selectedTable" floorMapScrollTop="state.floorMapScrollTop"
createTable.bind="createTable" duplicateTable.bind="duplicateTable" renameTable.bind="renameTable"
changeSeatsNum.bind="changeSeatsNum" changeShape.bind="changeShape" setTableColor.bind="setTableColor"
setFloorColor.bind="setFloorColor" deleteTable.bind="deleteTable"
/>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="TableWidget" owl="1">
<div class="table" t-on-click.stop="() => props.onClick(props.table)">
<span class="table-cover" t-att-class="{ full: fill >= 1 }"></span>
<span t-att-class="orderCountClass" t-att-hidden="orderCount === 0">
<t t-esc="orderCount" />
</span>
<span class="label">
<t t-esc="props.table.name" />
</span>
<span class="table-seats">
<t t-esc="customerCountDisplay" />
</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="PaymentScreen" t-inherit="point_of_sale.PaymentScreen" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('payment-screen')]" position="inside">
<t t-portal="'.pos .back-to-floor-portal'" position="before">
<BackToFloorButton mobileSearchBarIsShown="props.mobileSearchBarIsShown"/>
</t>
</xpath>
<xpath expr="//div[hasclass('button') and hasclass('next')]" position="attributes">
<attribute name="t-att-hidden">env.pos.config.set_tip_after_payment and !currentOrder.is_paid()</attribute>
</xpath>
<xpath expr="//div[hasclass('button') and hasclass('back')]/span[hasclass('back_text')]" position="replace">
<t t-if="env.pos.config.set_tip_after_payment and currentOrder.is_paid()">
<span class="back_text">Keep Open</span>
</t>
<t t-else="">$0</t>
</xpath>
<xpath expr="//div[hasclass('button') and hasclass('next')]/span[hasclass('next_text')]" position="replace">
<t t-if="env.pos.config.set_tip_after_payment and currentOrder.is_paid()">
<span class="back_text">Close Tab</span>
</t>
<t t-else="">$0</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="PaymentScreenPaymentLines" t-inherit="point_of_sale.PaymentScreenPaymentLines" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('send_payment_reversal')]/.." position="replace">
<t t-if="line.canBeAdjusted() &amp;&amp; line.order.get_total_paid() &lt; line.order.get_total_with_tax()">
<div class="button send_adjust_amount" title="Adjust Amount" t-on-click="() => this.trigger('send-payment-adjust', line)">
Adjust Amount
</div>
</t>
<t t-elif="line.can_be_reversed">
<div class="button send_payment_reversal" title="Reverse Payment" t-on-click="() => this.trigger('send-payment-reverse', line)">
Reverse
</div>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderlineNoteButton" owl="1">
<div class="control-button">
<i class="fa fa-tag" />
<span> </span>
<span>Internal Note</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="PrintBillButton" owl="1">
<span class="control-button order-printbill">
<i class="fa fa-print"></i>
<span> </span>
<span>Bill</span>
</span>
</t>
</templates>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SplitBillButton" owl="1">
<span class="control-button order-split">
<i class="fa fa-files-o"></i>
<span> </span>
<span>Split</span>
</span>
</t>
</templates>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SubmitOrderButton" owl="1">
<span class="control-button" t-att-class="addedClasses" t-on-click="_onClick">
<i class="fa fa-cutlery"></i>
<span> </span>
<span>Order</span>
</span>
</t>
</templates>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="TableGuestsButton" owl="1">
<div class="control-button">
<span class="control-button-number">
<t t-esc="nGuests" />
</span>
<span> </span>
<span>Guests</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="TransferOrderButton" owl="1">
<div class="control-button">
<i class="fa fa-arrow-right" />
<span> </span>
<span>Transfer</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension" owl="1">
<xpath expr="//ul[hasclass('info-list')]" position="inside">
<t t-if="props.line.get_note()">
<li class="info orderline-note">
<i class="fa fa-tag" role="img" aria-label="Note" title="Note"/>
<t t-esc="props.line.get_note()" />
</li>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('product-screen')]" position="inside">
<t t-portal="'.pos .back-to-floor-portal'" position="before">
<BackToFloorButton mobileSearchBarIsShown="props.mobileSearchBarIsShown"/>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="inside">
<t t-if="props.isBill">
<div>PRO FORMA</div>
</t>
</xpath>
<xpath expr="//div[hasclass('receipt-change')]" position="attributes">
<attribute name="t-if">!props.isBill</attribute>
</xpath>
<xpath expr="//div[hasclass('cashier')]" position="after">
<t t-if="receipt.table">
at table <t t-esc="receipt.table" />
</t>
<t t-if="receipt.table and receipt.customer_count">
<div>Guests: <t t-esc="receipt.customer_count" /></div>
</t>
</xpath>
<xpath expr="//div[hasclass('before-footer')]" position="after">
<t t-if="props.isBill and env.pos.config.set_tip_after_payment">
<div class="tip-form">
<div class="title">For convenience, we are providing the following gratuity calculations:</div>
<div class="percentage-options">
<div class="option">
<div>15%</div>
<div class="amount">
<t t-esc="env.pos.format_currency(receipt.total_with_tax * 0.15)"></t>
</div>
</div>
<div class="option">
<div>20%</div>
<div class="amount">
<t t-esc="env.pos.format_currency(receipt.total_with_tax * 0.20)"></t>
</div>
</div>
<div class="option">
<div>25%</div>
<div class="amount">
<t t-esc="env.pos.format_currency(receipt.total_with_tax * 0.25)"></t>
</div>
</div>
</div>
</div>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ReceiptScreen" t-inherit="point_of_sale.ReceiptScreen" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('receipt-screen')]" position="inside">
<t t-portal="'.pos .back-to-floor-portal'" position="before">
<BackToFloorButton mobileSearchBarIsShown="props.mobileSearchBarIsShown"
onClick.bind="onBackToFloorButtonClick"
/>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SplitBillScreen" owl="1">
<div class="splitbill-screen screen">
<div class="contents">
<div class="top-content">
<span class="button back" t-on-click="back">
<i class="fa fa-angle-double-left"></i>
<span> </span>
<span>Back</span>
</span>
<div class="top-content-center">
<h1>Bill Splitting</h1>
</div>
</div>
<div t-if="newOrder" class="main">
<div class="lines">
<div class="order">
<ul class="orderlines">
<t t-foreach="orderlines" t-as="line" t-key="line.cid">
<SplitOrderline line="line" split="splitlines[line.id]" />
</t>
</ul>
</div>
</div>
<div class="controls">
<div class="order-info">
<span class="subtotal">
<t t-esc="env.pos.format_currency(newOrder.get_subtotal())" />
</span>
</div>
<div class="pay-button">
<div class="button" t-on-click="proceed">
<i class="fa fa-chevron-right" />
<span> </span>
<span>Payment</span>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SplitOrderline" owl="1">
<li class="orderline" t-att-class="{ selected: isSelected, partially: props.split.quantity !== props.line.get_quantity() }">
<span class="product-name">
<t t-esc="props.line.get_product().display_name" />
</span>
<span class="price">
<t t-esc="env.pos.format_currency(props.line.get_display_price())" />
</span>
<ul class="info-list">
<t t-if="props.line.get_quantity_str() !== '1'">
<li class="info">
<t t-if="isSelected and props.line.get_unit().is_pos_groupable">
<em class="big">
<t t-esc="props.split.quantity" />
</em>
/
<t t-esc="props.line.get_quantity_str()" />
</t>
<t t-if="!(isSelected and props.line.get_unit().is_pos_groupable)">
<em>
<t t-esc="props.line.get_quantity_str()" />
</em>
</t>
<t t-esc="props.line.get_unit().name" />
at
<t t-esc="env.pos.format_currency(props.line.get_unit_price())" />
/
<t t-esc="props.line.get_unit().name" />
</li>
</t>
<t t-if="props.line.get_discount_str() !== '0'">
<li class="info">
<span>With a </span>
<em>
<t t-esc="props.line.get_discount_str()" />
<span>%</span>
</em>
<span> discount</span>
</li>
</t>
</ul>
</li>
</t>
</templates>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="TicketScreen" t-inherit="point_of_sale.TicketScreen" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('header-row')]//div[@name='delete']" position="before">
<div t-if="env.pos.config.iface_floorplan" class="col" name="table">Table</div>
<div t-if="_state.ui.filter == 'TIPPING'" class="col end narrow" name="tip">Tip</div>
</xpath>
<xpath expr="//div[hasclass('order-row')]//div[@name='delete']" position="before">
<div t-if="env.pos.config.iface_floorplan" class="col" name="table">
<t t-if="order.tableId">
<div t-if="env.isMobile">Table</div>
<div><t t-esc="getTable(order)"></t></div>
</t>
</div>
<div t-if="_state.ui.filter == 'TIPPING'" class="col end narrow" name="tip">
<div t-if="env.isMobile">Tip</div>
<div><TipCell order="order" /></div>
</div>
</xpath>
<xpath expr="//div[hasclass('buttons')]" position="inside">
<button class="settle-tips" t-if="_state.ui.filter == 'TIPPING'" t-on-click="settleTips">Settle</button>
</xpath>
</t>
<t t-name="TipCell" owl="1">
<div class="tip-cell" t-on-click.stop="editTip">
<t t-if="state.isEditing">
<input type="text" name="tip-amount" t-ref="autofocus" t-model="orderUiState.inputTipAmount" t-on-blur="onBlur" t-on-keydown="onKeydown" />
</t>
<div t-else="">
<t t-esc="tipAmountStr"></t>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.TipScreen" owl="1">
<div class="tip-screen screen">
<div class="pos-receipt-container"/>
<div class="screen-content">
<div class="top-content">
<span class="button back" t-on-click="() => this.showScreen('FloorScreen')">
<i class="fa fa-angle-double-left"></i>
<span> </span>
<span>Back</span>
</span>
<span class="button" t-if="env.proxy.printer" t-on-click="printTipReceipt">
<i class="fa fa-print"></i>
<span> </span>
<span>Reprint receipts</span>
</span>
<div class="top-content-center">
<h1>Add a tip</h1>
</div>
<div class="button highlight next" t-on-click="validateTip">
Settle <i class="fa fa-angle-double-right"></i>
</div>
</div>
<div class="tip-options">
<div class="total-amount">
<t t-esc="overallAmountStr" />
</div>
<div class="tip-amount-options">
<div class="percentage-amounts">
<t t-foreach="percentageTips" t-as="tip" t-key="tip.percentage">
<div class="button" t-on-click="() => { state.inputTipAmount = tip.amount.toFixed(2); }">
<div class="percentage">
<t t-esc="tip.percentage"></t>
</div>
<div class="amount">
<t t-esc="env.pos.format_currency(tip.amount)" />
</div>
</div>
</t>
</div>
<div class="no-tip" t-on-click="() => { state.inputTipAmount = '0'; }">
<div class="button">No Tip</div>
</div>
<div class="custom-amount-form">
<div class="item label">Amount</div>
<div class="item input">
<input type="text" t-model="state.inputTipAmount" t-att-data-amount="state.inputTipAmount" />
<div class="currency">
<t t-esc="env.pos.getCurrencySymbol()" />
</div>
</div>
</div>
</div>
</div>
</div>
<t t-portal="'.pos .back-to-floor-portal'" position="before">
<BackToFloorButton mobileSearchBarIsShown="props.mobileSearchBarIsShown"/>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="TipReceipt" owl="1">
<div class="pos-receipt">
<t t-if="receipt.company.logo">
<img class="pos-receipt-logo" t-att-src="receipt.company.logo" alt="Logo"/>
<br/>
</t>
<t t-if="!receipt.company.logo">
<h2 class="pos-receipt-center-align">
<t t-esc="receipt.company.name" />
</h2>
<br/>
</t>
<div class="pos-receipt-contact">
<t t-if="receipt.company.contact_address">
<div><t t-esc="receipt.company.contact_address" /></div>
</t>
<t t-if="receipt.company.phone">
<div>Tel:<t t-esc="receipt.company.phone" /></div>
</t>
<t t-if="receipt.company.vat">
<div>VAT:<t t-esc="receipt.company.vat" /></div>
</t>
<t t-if="receipt.company.email">
<div><t t-esc="receipt.company.email" /></div>
</t>
<t t-if="receipt.company.website">
<div><t t-esc="receipt.company.website" /></div>
</t>
<t t-if="receipt.header_html">
<t t-out="receipt.header_html" />
</t>
<t t-if="!receipt.header_html and receipt.header">
<div><t t-esc="receipt.header" /></div>
</t>
<t t-if="receipt.cashier">
<div class="cashier">
<div>--------------------------------</div>
<div>Served by <t t-esc="receipt.cashier" /></div>
</div>
</t>
</div>
<br/>
<div class="pos-payment-terminal-receipt">
<t t-out="data"/>
</div>
<br/>
<div class="subtotal">
<span>Subtotal</span>
<div class="pos-receipt-right-align"><t t-esc="total"/></div>
</div>
<br/>
<div class="tip">
<span>Tip:</span>
<div class="pos-receipt-right-align">________________________</div>
</div>
<br/>
<div class="total">
<span>Total:</span>
<div class="pos-receipt-right-align">________________________</div>
</div>
<br/>
<br/>
<div class="signature">
<div>______________________________________________</div>
<div>Signature</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderChangeReceipt">
<div class="pos-receipt">
<div class="pos-receipt-order-data"><t t-esc="changes.name" /></div>
<t t-if="changes.floor_name || changes.table_name">
<br />
<div class="pos-receipt-title">
<t t-esc="changes.floor_name" /> / <t t-esc="changes.table_name"/>
</div>
</t>
<br />
<br />
<t t-if="changes.cancelled.length > 0">
<div class="pos-order-receipt-cancel">
<div class="pos-receipt-title">
CANCELLED
<t t-esc='changes.time.hours'/>:<t t-esc='changes.time.minutes'/>
</div>
<br />
<br />
<t t-foreach="changes.cancelled" t-as="change">
<div class="multiprint-flex">
<span class="product-quantity" t-esc="change.quantity"/>
<span class="product-name" t-esc="change.name"/>
</div>
<t t-if="change.note">
<div>
NOTE
<span class="pos-receipt-right-align">...</span>
</div>
<div><span class="pos-receipt-left-padding">--- <t t-esc="change.note" /></span></div>
<br/>
</t>
</t>
<br />
<br />
</div>
</t>
<t t-if="changes.new.length > 0">
<div class="pos-receipt-title">
NEW
<t t-esc='changes.time.hours'/>:<t t-esc='changes.time.minutes'/>
</div>
<br />
<br />
<t t-foreach="changes.new" t-as="change">
<div class="multiprint-flex">
<span class="product-quantity" t-esc="change.quantity"/>
<span class="product-name" t-esc="change.name"/>
</div>
<t t-if="change.note">
<div>
NOTE
<span class="pos-receipt-right-align">...</span>
</div>
<div><span class="pos-receipt-left-padding">--- <t t-esc="change.note" /></span></div>
<br/>
</t>
</t>
<br />
<br />
</t>
</div>
</t>
</templates>