[REF] point_of_sale: migration to OWL framework

This commit is contained in:
Joseph Caburnay
2020-05-12 12:48:58 +00:00
committed by Pierre Masereel
parent cbd6360570
commit c6397ab3b3
162 changed files with 10443 additions and 6629 deletions
+61 -4
View File
@@ -17,8 +17,9 @@
'wizard/pos_box.xml',
'wizard/pos_details.xml',
'wizard/pos_payment.xml',
'views/pos_templates.xml',
'views/point_of_sale_template.xml',
'views/pos_assets_common.xml',
'views/pos_assets_index.xml',
'views/pos_assets_qunit.xml',
'views/point_of_sale_report.xml',
'views/point_of_sale_view.xml',
'views/pos_order_view.xml',
@@ -30,7 +31,7 @@
'views/pos_config_view.xml',
'views/pos_session_view.xml',
'views/point_of_sale_sequence.xml',
'views/point_of_sale.xml',
'views/customer_facing_display.xml',
'data/point_of_sale_data.xml',
'views/pos_order_report_view.xml',
'views/account_statement_view.xml',
@@ -46,6 +47,62 @@
],
'installable': True,
'application': True,
'qweb': ['static/src/xml/pos.xml'],
'qweb': [
'static/src/xml/Chrome.xml',
'static/src/xml/debug_manager.xml',
'static/src/xml/Screens/ProductScreen/ProductScreen.xml',
'static/src/xml/Screens/ClientListScreen/ClientLine.xml',
'static/src/xml/Screens/ClientListScreen/ClientDetails.xml',
'static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml',
'static/src/xml/Screens/ClientListScreen/ClientListScreen.xml',
'static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml',
'static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml',
'static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml',
'static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml',
'static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml',
'static/src/xml/Screens/PaymentScreen/PaymentScreen.xml',
'static/src/xml/Screens/ProductScreen/Orderline.xml',
'static/src/xml/Screens/ProductScreen/OrderSummary.xml',
'static/src/xml/Screens/ProductScreen/OrderWidget.xml',
'static/src/xml/Screens/ProductScreen/NumpadWidget.xml',
'static/src/xml/Screens/ProductScreen/ActionpadWidget.xml',
'static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml',
'static/src/xml/Screens/ProductScreen/CategoryButton.xml',
'static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml',
'static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml',
'static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml',
'static/src/xml/Screens/ProductScreen/ProductItem.xml',
'static/src/xml/Screens/ProductScreen/ProductList.xml',
'static/src/xml/Screens/ProductScreen/ProductsWidget.xml',
'static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml',
'static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml',
'static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml',
'static/src/xml/Screens/ScaleScreen/ScaleScreen.xml',
'static/src/xml/ChromeWidgets/OrderSelector.xml',
'static/src/xml/ChromeWidgets/CashierName.xml',
'static/src/xml/ChromeWidgets/ProxyStatus.xml',
'static/src/xml/ChromeWidgets/SyncNotification.xml',
'static/src/xml/ChromeWidgets/HeaderButton.xml',
'static/src/xml/ChromeWidgets/SaleDetailsButton.xml',
'static/src/xml/SaleDetailsReport.xml',
'static/src/xml/Misc/Draggable.xml',
'static/src/xml/Misc/NotificationSound.xml',
'static/src/xml/ChromeWidgets/DebugWidget.xml',
'static/src/xml/Popups/ErrorPopup.xml',
'static/src/xml/Popups/ErrorBarcodePopup.xml',
'static/src/xml/Popups/ConfirmPopup.xml',
'static/src/xml/Popups/TextInputPopup.xml',
'static/src/xml/Popups/TextAreaPopup.xml',
'static/src/xml/Popups/ErrorTracebackPopup.xml',
'static/src/xml/Popups/SelectionPopup.xml',
'static/src/xml/Popups/EditListInput.xml',
'static/src/xml/Popups/EditListPopup.xml',
'static/src/xml/Popups/NumberPopup.xml',
'static/src/xml/Popups/OfflineErrorPopup.xml',
'static/src/xml/Popups/OrderImportPopup.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml',
'static/src/xml/ChromeWidgets/ClientScreenButton.xml',
],
'website': 'https://www.odoo.com/page/point-of-sale-shop',
}
+15
View File
@@ -43,6 +43,21 @@ class PosController(http.Controller):
}
return request.render('point_of_sale.index', qcontext=context)
@http.route('/pos/web/tests', type='http', auth="user")
def test_suite(self, mod=None, **kwargs):
domain = [
('state', '=', 'opened'),
('user_id', '=', request.session.uid),
('rescue', '=', False)
]
pos_session = request.env['pos.session'].sudo().search(domain, limit=1)
session_info = request.env['ir.http'].session_info()
session_info['user_context']['allowed_company_ids'] = pos_session.company_id.ids
context = {
'session_info': session_info,
}
return request.render('point_of_sale.qunit_suite', qcontext=context)
@http.route('/pos/sale_details_report', type='http', auth='user')
def print_sale_details(self, date_start=False, date_stop=False, **kw):
r = request.env['report.point_of_sale.report_saledetails']
+75 -4
View File
@@ -1772,6 +1772,21 @@ td {
margin-left: 120px;
}
/* These css declarations is to make sure that the list of clients
* always occupies the remaining space regardless when the client
* details is displayed or not.
*/
.clientlist-screen .client-window {
display: flex;
flex-flow: column;
height: 100%;
}
.clientlist-screen .client-window section.list {
flex: 1;
overflow: auto;
}
/* ********* The OrderWidget ********* */
@@ -2102,6 +2117,9 @@ td {
.pos .debug-widget .content{
overflow: hidden;
}
.pos .debug-widget header {
position: relative;
}
.pos .debug-widget h1{
background:black;
padding-top: 10px;
@@ -2164,9 +2182,11 @@ td {
background-color: rgba(0,0,0,0.5);
z-index:1000;
}
.pos .modal-dialog header{
position: relative;
}
.pos .modal-dialog .popup{
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
margin: auto;
width:500px;
height:400px;
@@ -2179,6 +2199,10 @@ td {
z-index:1200;
font-family: 'Lato';
font-family: Lato;
/* position the popup at center and and still making it draggable*/
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.pos .modal-dialog .popup-confirm{
height: 250px;
@@ -2238,6 +2262,11 @@ td {
background: rgba(0,0,0,0.05);
}
.pos .popup .button.dont-show-again {
width: 130px;
}
.pos .popup .button.icon {
width: 40px;
font-size: 20px;
@@ -2287,12 +2316,12 @@ td {
box-sizing: border-box;
width: 80%;
}
.pos .popup .packlot-lines{
.pos .popup .list-lines{
overflow: auto;
height: 250px;
margin: 10px;
}
.pos .popup .packlot-line-input {
.pos .popup .list-line-input {
margin: 3px;
}
@@ -2387,7 +2416,6 @@ td {
}
.pos .popup-password .popup-numpad {
width: 190px;
text-align: left;
}
.pos .popup-password .popup-input {
width: 70%;
@@ -2474,3 +2502,46 @@ td {
.pos .not-supported-browser img{
border-collapse: separate;
}
.fade-enter-active, .fade-leave-active {
transition: opacity .2s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
.swing-enter-active, .swing-leave-active {
transition: opacity 0.8s;
}
.swing-enter, .swing-leave-to {
opacity: 0;
}
/*
We block the top-header when a temp screen is displayed.
Similar to blocking the whole ui when a popup is displayed.
*/
.pos .block-top-header {
position: absolute;
left: 0;
top: 0;
width: 100%;
height:100%;
background-color: rgba(0,0,0,0.5);
z-index:1000;
}
.drag-handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}
.drag-handle:active {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
@@ -0,0 +1,426 @@
odoo.define('point_of_sale.Chrome', function(require) {
'use strict';
const { useState, useRef } = owl.hooks;
const { debounce } = owl.utils;
const { loadCSS } = require('web.ajax');
const { useListener } = require('web.custom_hooks');
const { CrashManager } = require('web.CrashManager');
const { BarcodeEvents } = require('barcodes.BarcodeEvents');
const PosComponent = require('point_of_sale.PosComponent');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
const Registries = require('point_of_sale.Registries');
// This is kind of a trick.
// We get a reference to the whole exports so that
// when we create an instance of one of the classes,
// we instantiate the extended one.
const models = require('point_of_sale.models');
/**
* Chrome is the root component of the PoS App.
*/
class Chrome extends PopupControllerMixin(PosComponent) {
constructor() {
super(...arguments);
useListener('show-main-screen', this.__showScreen);
useListener('toggle-debug-widget', debounce(this._toggleDebugWidget, 100));
useListener('show-temp-screen', this.__showTempScreen);
useListener('close-temp-screen', this.__closeTempScreen);
useListener('close-pos', this._closePos);
useListener('loading-skip-callback', () => this._loadingSkipCallback());
useListener('play-sound', this._onPlaySound);
useListener('set-sync-status', this._onSetSyncStatus);
NumberBuffer.activate();
this.state = useState({
uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING'
debugWidgetIsShown: true,
hasBigScrollBars: false,
sound: { src: null },
});
this.loading = useState({
message: 'Loading',
skipButtonIsShown: false,
});
this.mainScreen = useState({ name: null, component: null });
this.mainScreenProps = {};
this.tempScreen = useState({ isShown: false, name: null, component: null });
this.tempScreenProps = {};
this.progressbar = useRef('progressbar');
this.previous_touch_y_coordinate = -1;
}
// OVERLOADED METHODS //
mounted() {
// remove default webclient handlers that induce click delay
$(document).off();
$(window).off();
$('html').off();
$('body').off();
// The above lines removed the bindings, but we really need them for the barcode
BarcodeEvents.start();
}
willUnmount() {
BarcodeEvents.stop();
}
destroy() {
super.destroy(...arguments);
this.env.pos.destroy();
}
catchError(error) {
console.error(error);
}
// GETTERS //
get clientScreenButtonIsShown() {
return (
this.env.pos.config.use_proxy && this.env.pos.config.iface_customer_facing_display
);
}
/**
* Startup screen can be based on pos config so the startup screen
* is only determined after pos data is completely loaded.
*
* NOTE: Wait for pos data to be completed before calling this getter.
*/
get startScreen() {
if (this.state.uiState !== 'READY') {
console.warn(
`Accessing startScreen of Chrome component before 'state.uiState' to be 'READY' is not recommended.`
);
}
return { name: 'ProductScreen' };
}
// CONTROL METHODS //
/**
* Call this function after the Chrome component is mounted.
* This will load pos and assign it to the environment.
*/
async start() {
try {
// Instead of passing chrome to the instantiation the PosModel,
// we inject functions needed by pos.
// This way, we somehow decoupled Chrome from PosModel.
// We can then test PosModel independently from Chrome by supplying
// mocked version of these default attributes.
const posModelDefaultAttributes = {
rpc: this.rpc.bind(this),
session: this.env.session,
do_action: this.props.webClient.do_action.bind(this.props.webClient),
setLoadingMessage: this.setLoadingMessage.bind(this),
showLoadingSkip: this.showLoadingSkip.bind(this),
setLoadingProgress: this.setLoadingProgress.bind(this),
};
this.env.pos = new models.PosModel(posModelDefaultAttributes);
await this.env.pos.ready;
this._buildChrome();
this.env.pos.set(
'selectedCategoryId',
this.env.pos.config.iface_start_categ_id
? this.env.pos.config.iface_start_categ_id[0]
: 0
);
this.state.uiState = 'READY';
this.env.pos.on('change:selectedOrder', this._showSavedScreen, this);
this._showStartScreen();
this.env.pos.push_orders(); // push order in the background, no need to await
// Allow using the app even if not all the images are loaded.
// Basically, preload the images in the background.
setTimeout(() => this._preloadImages());
} catch (error) {
let title = 'Unknown Error',
body;
if (error.message && [100, 200, 404, -32098].includes(error.message.code)) {
// this is the signature of rpc error
if (error.message.code === -32098) {
title = 'Network Failure (XmlHttpRequestError)';
body =
'The Point of Sale could not be loaded due to a network problem.\n' +
'Please check your internet connection.';
} else if (error.message.code === 200) {
title = error.message.data.message || this.env._t('Server Error');
body =
error.message.data.debug ||
this.env._t(
'The server encountered an error while receiving your order.'
);
}
} else if (error instanceof Error) {
title = error.message;
body = error.stack;
}
await this.showPopup('ErrorTracebackPopup', {
title,
body,
exitButtonIsShown: true,
});
}
}
// EVENT HANDLERS //
_showStartScreen() {
const { name, props } = this.startScreen;
this.showScreen(name, props);
}
/**
* Show the screen saved in the order when the `selectedOrder` of pos is changed.
* @param {models.PosModel} pos
* @param {models.Order} newSelectedOrder
*/
_showSavedScreen(pos, newSelectedOrder) {
const { name, props } = this._getSavedScreen(newSelectedOrder);
this.showScreen(name, props);
}
_getSavedScreen(order) {
return order.get_screen_data('screen') || { name: 'ProductScreen' };
}
__showTempScreen(event) {
const { name, props, resolve } = event.detail;
this.tempScreen.isShown = true;
this.tempScreen.name = name;
this.tempScreen.component = this.constructor.components[name];
this.tempScreenProps = Object.assign({}, props, { resolve });
}
__closeTempScreen() {
this.tempScreen.isShown = false;
}
__showScreen({ detail: { name, props } }) {
// 1. Set the information of the screen to display.
this.mainScreen.name = name;
this.mainScreen.component = this.constructor.components[name];
this.mainScreenProps = props || {};
// 2. Save the screen to the order.
// - This screen is shown when the order is selected.
this._setScreenData(name, props);
}
/**
* Set the latest screen to the current order. This is done so that
* when the order is selected again, the ui returns to the latest screen
* saved in the order.
*
* @param {string} name Screen name
* @param {Object} props props for the Screen component
*/
_setScreenData(name, props) {
const order = this.env.pos.get_order();
if (order) {
order.set_screen_data('screen', { name, props });
}
}
async _closePos() {
// If pos is not properly loaded, we just go back to /web without
// doing anything in the order data.
if (!this.env.pos || this.env.pos.db.get_orders().length === 0) {
window.location = '/web#action=point_of_sale.action_client_pos_menu';
}
if (this.env.pos.db.get_orders().length) {
// If there are orders in the db left unsynced, we try to sync.
// If sync successful, close without asking.
// Otherwise, ask again saying that some orders are not yet synced.
try {
await this.env.pos.push_orders();
window.location = '/web#action=point_of_sale.action_client_pos_menu';
} catch (error) {
console.warn(error);
const reason = this.env.pos.get('failed')
? this.env._t(
'Some orders could not be submitted to ' +
'the server due to configuration errors. ' +
'You can exit the Point of Sale, but do ' +
'not close the session before the issue ' +
'has been resolved.'
)
: this.env._t(
'Some orders could not be submitted to ' +
'the server due to internet connection issues. ' +
'You can exit the Point of Sale, but do ' +
'not close the session before the issue ' +
'has been resolved.'
);
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Offline Orders'),
body: reason,
});
if (confirmed) {
this.state.uiState = 'CLOSING';
this.loading.skipButtonIsShown = false;
this.setLoadingMessage(this.env._t('Closing ...'));
window.location = '/web#action=point_of_sale.action_client_pos_menu';
}
}
}
}
_toggleDebugWidget() {
this.state.debugWidgetIsShown = !this.state.debugWidgetIsShown;
}
_onPlaySound({ detail: name }) {
let src;
if (name === 'error') {
src = "/point_of_sale/static/src/sounds/error.wav";
} else if (name === 'bell') {
src = "/point_of_sale/static/src/sounds/bell.wav";
}
this.state.sound.src = src;
}
_onSetSyncStatus({ detail: { status, pending }}) {
this.env.pos.set('synch', { status, pending });
}
// TO PASS AS PARAMETERS //
setLoadingProgress(fac) {
if (this.progressbar.el) {
this.progressbar.el.style.width = `${Math.floor(fac * 100)}%`;
}
}
setLoadingMessage(msg, progress) {
this.loading.message = msg;
if (typeof progress !== 'undefined') {
this.setLoadingProgress(progress);
}
}
/**
* Show Skip button in the loading screen and allow to assign callback
* when the button is pressed.
*
* @param {Function} callback function to call when Skip button is pressed.
*/
showLoadingSkip(callback) {
if (callback) {
this.loading.skipButtonIsShown = true;
this._loadingSkipCallback = callback;
}
}
// MISC METHODS //
_preloadImages() {
for (let product of this.env.pos.db.get_product_by_category(0)) {
const image = new Image();
image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
}
for (let category of Object.values(this.env.pos.db.category_by_id)) {
if (category.id == 0) continue;
const image = new Image();
image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
}
const staticImages = ['backspace.png', 'bc-arrow-big.png'];
for (let imageName of staticImages) {
const image = new Image();
image.src = `/point_of_sale/static/src/img/${imageName}`;
}
}
_buildChrome() {
if ($.browser.chrome) {
var chrome_version = $.browser.version.split('.')[0];
if (parseInt(chrome_version, 10) >= 50) {
loadCSS('/point_of_sale/static/src/css/chrome50.css');
}
}
if (this.env.pos.config.iface_big_scrollbars) {
this.state.uiState.hasBigScrollBars = true;
}
this._disableRubberbanding();
this._disableBackspaceBack();
this._replaceCrashmanager();
}
// replaces the error handling of the existing crashmanager which
// uses jquery dialog to display the error, to use the pos popup
// instead
_replaceCrashmanager() {
var self = this;
CrashManager.include({
show_error: function (error) {
if (self.env.pos) {
// self == this component
self.showPopup('ErrorTracebackPopup', {
title: error.type,
body: error.message + '\n' + error.data.debug + '\n',
});
} else {
// this == CrashManager instance
this._super(error);
}
},
});
}
_disableRubberbanding() {
var self = this;
document.body.addEventListener('touchstart', function (event) {
self.previous_touch_y_coordinate = event.touches[0].clientY;
});
// prevent the pos body from being scrollable.
document.body.addEventListener('touchmove', function (event) {
var node = event.target;
var current_touch_y_coordinate = event.touches[0].clientY;
var scrolling_down;
if (current_touch_y_coordinate < self.previous_touch_y_coordinate) {
scrolling_down = true;
} else {
scrolling_down = false;
}
while (node) {
if (
node.classList &&
node.classList.contains('touch-scrollable') &&
self._scrollable(node, scrolling_down)
) {
return;
}
node = node.parentNode;
}
event.preventDefault();
});
}
// prevent backspace from performing a 'back' navigation
_disableBackspaceBack() {
$(document).on('keydown', function (e) {
if (e.which === 8 && !$(e.target).is('input, textarea')) {
e.preventDefault();
}
});
}
_scrollable(element, scrolling_down) {
var $element = $(element);
var scrollable = true;
if (!scrolling_down && $element.scrollTop() <= 0) {
scrollable = false;
} else if (
scrolling_down &&
$element.scrollTop() + $element.height() >= element.scrollHeight
) {
scrollable = false;
}
return scrollable;
}
}
Chrome.template = 'Chrome';
Registries.Component.add(Chrome);
return Chrome;
});
@@ -0,0 +1,23 @@
odoo.define('point_of_sale.CashierName', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
// Previously UsernameWidget
class CashierName extends PosComponent {
get username() {
const cashier = this.env.pos.get_cashier();
if (cashier) {
return cashier.name;
} else {
return '';
}
}
}
CashierName.template = 'CashierName';
Registries.Component.add(CashierName);
return CashierName;
});
@@ -0,0 +1,87 @@
odoo.define('point_of_sale.ClientScreenButton', function(require) {
'use strict';
const { useState } = owl;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
// Formerly ClientScreenWidget
class ClientScreenButton extends PosComponent {
constructor() {
super(...arguments);
this.state = useState({ status: 'failure' });
this._start();
}
get message() {
return {
success: '',
warning: this.env._t('Connected, Not Owned'),
failure: this.env._t('Disconnected'),
not_found: this.env._t('Client Screen Unsupported. Please upgrade the IoT Box'),
}[this.state.status];
}
async onClick() {
try {
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
const ownership = await this.env.pos.proxy.take_ownership_over_client_screen(
renderedHtml
);
if (typeof ownership === 'string') {
ownership = JSON.parse(ownership);
}
if (ownership.status === 'success') {
this.state.status = 'success';
} else {
this.state.status = 'warning';
}
if (!this.env.pos.proxy.posbox_supports_display) {
this.env.pos.proxy.posbox_supports_display = true;
this._start();
}
} catch (error) {
if (typeof error == 'undefined') {
this.state.status = 'failure';
} else {
this.state.status = 'not_found';
}
}
}
_start() {
const self = this;
async function loop() {
if (self.env.pos.proxy.posbox_supports_display) {
try {
const ownership = await self.env.pos.proxy.test_ownership_of_client_screen();
if (typeof ownership === 'string') {
ownership = JSON.parse(ownership);
}
if (ownership.status === 'OWNER') {
self.state.status = 'success';
} else {
self.state.status = 'warning';
}
setTimeout(loop, 3000);
} catch (error) {
if (error.abort) {
// Stop the loop
return;
}
if (typeof error == 'undefined') {
self.state.status = 'failure';
} else {
self.state.status = 'not_found';
self.env.pos.proxy.posbox_supports_display = false;
}
setTimeout(loop, 3000);
}
}
}
loop();
}
}
ClientScreenButton.template = 'ClientScreenButton';
Registries.Component.add(ClientScreenButton);
return ClientScreenButton;
});
@@ -0,0 +1,161 @@
odoo.define('point_of_sale.DebugWidget', function (require) {
'use strict';
const { useState } = owl;
const { useRef } = owl.hooks;
const { getFileAsText } = require('point_of_sale.utils');
const { parse } = require('web.field_utils');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class DebugWidget extends PosComponent {
constructor() {
super(...arguments);
this.state = useState({
barcodeInput: '',
weightInput: '',
isPaidOrdersReady: false,
isUnpaidOrdersReady: false,
buffer: NumberBuffer.get(),
});
// NOTE: Perhaps this can still be improved.
// What we do here is loop thru the `event` elements
// then we assign animation that happens when the event is triggered
// in the proxy. E.g. if open_cashbox is sent, the open_cashbox element
// changes color from '#6CD11D' to '#1E1E1E' for a duration of 2sec.
this.eventElementsRef = {};
this.animations = {};
for (let eventName of ['open_cashbox', 'print_receipt', 'scale_read']) {
this.eventElementsRef[eventName] = useRef(eventName);
this.env.pos.proxy.add_notification(
eventName,
(() => {
if (this.animations[eventName]) {
this.animations[eventName].cancel();
}
const eventElement = this.eventElementsRef[eventName].el;
eventElement.style.backgroundColor = '#6CD11D';
this.animations[eventName] = eventElement.animate(
{ backgroundColor: ['#6CD11D', '#1E1E1E'] },
2000
);
}).bind(this)
);
}
}
mounted() {
NumberBuffer.on('buffer-update', this, this._onBufferUpdate);
}
willUnmount() {
NumberBuffer.off('buffer-update', this, this._onBufferUpdate);
}
toggleWidget() {
this.state.isShown = !this.state.isShown;
}
setWeight() {
var weightInKg = parse.float(this.state.weightInput);
if (!isNaN(weightInKg)) {
this.env.pos.proxy.debug_set_weight(weightInKg);
}
}
resetWeight() {
this.state.weightInput = '';
this.env.pos.proxy.debug_reset_weight();
}
barcodeScan() {
this.env.pos.barcode_reader.scan(this.state.barcodeInput);
}
barcodeScanEAN() {
const ean = this.env.pos.barcode_reader.barcode_parser.sanitize_ean(
this.state.barcodeInput || '0'
);
this.state.barcodeInput = ean;
this.env.pos.barcode_reader.scan(ean);
}
async deleteOrders() {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Delete Paid Orders ?'),
body: this.env._t(
'This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.'
),
});
if (confirmed) {
this.env.pos.db.remove_all_orders();
this.env.pos.set_synch('connected', 0);
}
}
async deleteUnpaidOrders() {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Delete Unpaid Orders ?'),
body: this.env._t(
'This operation will destroy all unpaid orders in the browser. You will lose all the unsaved data and exit the point of sale. This operation cannot be undone.'
),
});
if (confirmed) {
this.env.pos.db.remove_all_unpaid_orders();
window.location = '/';
}
}
_createBlob(contents) {
if (typeof contents !== 'string') {
contents = JSON.stringify(contents, null, 2);
}
return new Blob([contents]);
}
// IMPROVEMENT: Duplicated codes for downloading paid and unpaid orders.
// The implementation can be better.
preparePaidOrders() {
try {
this.paidOrdersBlob = this._createBlob(this.env.pos.export_paid_orders());
this.state.isPaidOrdersReady = true;
} catch (error) {
console.warn(error);
}
}
get paidOrdersFilename() {
return `${this.env._t('paid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`;
}
get paidOrdersURL() {
var URL = window.URL || window.webkitURL;
return URL.createObjectURL(this.paidOrdersBlob);
}
prepareUnpaidOrders() {
try {
this.unpaidOrdersBlob = this._createBlob(this.env.pos.export_unpaid_orders());
this.state.isUnpaidOrdersReady = true;
} catch (error) {
console.warn(error);
}
}
get unpaidOrdersFilename() {
return `${this.env._t('unpaid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`;
}
get unpaidOrdersURL() {
var URL = window.URL || window.webkitURL;
return URL.createObjectURL(this.unpaidOrdersBlob);
}
async importOrders(event) {
const file = event.target.files[0];
if (file) {
const report = this.env.pos.import_orders(await getFileAsText(file));
await this.showPopup('OrderImportPopup', { report });
}
}
refreshDisplay() {
this.env.pos.proxy.message('display_refresh', {});
}
_onBufferUpdate(buffer) {
this.state.buffer = buffer;
}
get bufferRepr() {
return `"${this.state.buffer}"`;
}
}
DebugWidget.template = 'DebugWidget';
Registries.Component.add(DebugWidget);
return DebugWidget;
});
@@ -0,0 +1,36 @@
odoo.define('point_of_sale.HeaderButton', function(require) {
'use strict';
const { useState } = owl;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
// Previously HeaderButtonWidget
// This is the close session button
class HeaderButton extends PosComponent {
constructor() {
super(...arguments);
this.state = useState({ label: 'Close' });
this.confirmed = null;
}
get translatedLabel() {
return this.env._t(this.state.label);
}
onClick() {
if (!this.confirmed) {
this.state.label = 'Confirm';
this.confirmed = setTimeout(() => {
this.state.label = 'Close';
this.confirmed = null;
}, 2000);
} else {
this.trigger('close-pos');
}
}
}
HeaderButton.template = 'HeaderButton';
Registries.Component.add(HeaderButton);
return HeaderButton;
});
@@ -0,0 +1,49 @@
odoo.define('point_of_sale.OrderSelector', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
// Previously OrderSelectorWidget
class OrderSelector extends PosComponent {
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
}
willUnmount() {
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
}
selectOrder(order) {
this.env.pos.set_order(order);
}
addNewOrder() {
this.env.pos.add_new_order();
}
get orderList() {
return this.env.pos.get_order_list();
}
async deleteCurrentOrder() {
const order = this.env.pos.get_order();
if (!order) {
return;
} else if (!order.is_empty()) {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Destroy Current Order ?'),
body: this.env._t('You will lose any data associated with the current order'),
});
if (confirmed) this.env.pos.delete_current_order();
} else {
this.env.pos.delete_current_order();
}
}
creationTime(order) {
return moment(order.creation_date).format('hh:mm');
}
}
OrderSelector.template = 'OrderSelector';
Registries.Component.add(OrderSelector);
return OrderSelector;
});
@@ -0,0 +1,91 @@
odoo.define('point_of_sale.ProxyStatus', function(require) {
'use strict';
const { useState } = owl;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
// Previously ProxyStatusWidget
class ProxyStatus extends PosComponent {
constructor() {
super(...arguments);
const initialProxyStatus = this.env.pos.proxy.get('status');
this.state = useState({
status: initialProxyStatus.status,
msg: initialProxyStatus.msg,
});
this.statuses = ['connected', 'connecting', 'disconnected', 'warning'];
this.index = 0;
}
mounted() {
this.env.pos.proxy.on('change:status', this, this._onChangeStatus);
}
willUnmount() {
this.env.pos.proxy.off('change:status', this, this._onChangeStatus);
}
async onClick() {
try {
await this.env.pos.connect_to_proxy();
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
this.showPopup('ErrorPopup', error);
}
}
}
_onChangeStatus(posProxy, statusChange) {
this._setStatus(statusChange.newValue);
}
_setStatus(newStatus) {
if (newStatus.status === 'connected') {
var warning = false;
var msg = '';
if (this.env.pos.config.iface_scan_via_proxy) {
var scannerStatus = newStatus.drivers.scanner
? newStatus.drivers.scanner.status
: false;
if (scannerStatus != 'connected' && scannerStatus != 'connecting') {
warning = true;
msg += this.env._t('Scanner');
}
}
if (
this.env.pos.config.iface_print_via_proxy ||
this.env.pos.config.iface_cashdrawer
) {
var printerStatus = newStatus.drivers.printer
? newStatus.drivers.printer.status
: false;
if (printerStatus != 'connected' && printerStatus != 'connecting') {
warning = true;
msg = msg ? msg + ' & ' : msg;
msg += this.env._t('Printer');
}
}
if (this.env.pos.config.iface_electronic_scale) {
var scaleStatus = newStatus.drivers.scale
? newStatus.drivers.scale.status
: false;
if (scaleStatus != 'connected' && scaleStatus != 'connecting') {
warning = true;
msg = msg ? msg + ' & ' : msg;
msg += this.env._t('Scale');
}
}
msg = msg ? msg + ' ' + this.env._t('Offline') : msg;
this.state.status = warning ? 'warning' : 'connected';
this.state.msg = msg;
} else {
this.state.status = newStatus.status;
this.state.msg = newStatus.msg || '';
}
}
}
ProxyStatus.template = 'ProxyStatus';
Registries.Component.add(ProxyStatus);
return ProxyStatus;
});
@@ -0,0 +1,38 @@
odoo.define('point_of_sale.SaleDetailsButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class SaleDetailsButton extends PosComponent {
async onClick() {
// IMPROVEMENT: Perhaps put this logic in a parent component
// so that for unit testing, we can check if this simple
// component correctly triggers an event.
const saleDetails = await this.rpc({
model: 'report.point_of_sale.report_saledetails',
method: 'get_sale_details',
args: [false, false, false, [this.env.pos.pos_session.id]],
});
const report = this.env.qweb.renderToString(
'SaleDetailsReport',
Object.assign({}, saleDetails, {
date: new Date().toLocaleString(),
pos: this.env.pos,
})
);
const printResult = await this.env.pos.proxy.printer.print_receipt(report);
if (!printResult.successful) {
await this.showPopup('ErrorPopup', {
title: printResult.message.title,
body: printResult.message.body,
});
}
}
}
SaleDetailsButton.template = 'SaleDetailsButton';
Registries.Component.add(SaleDetailsButton);
return SaleDetailsButton;
});
@@ -0,0 +1,37 @@
odoo.define('point_of_sale.SyncNotification', function(require) {
'use strict';
const { useState } = owl;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
// Previously SynchNotificationWidget
class SyncNotification extends PosComponent {
constructor() {
super(...arguments);
const synch = this.env.pos.get('synch');
this.state = useState({ status: synch.status, msg: synch.pending });
}
mounted() {
this.env.pos.on(
'change:synch',
(pos, synch) => {
this.state.status = synch.status;
this.state.msg = synch.pending;
},
this
);
}
willUnmount() {
this.env.pos.on('change:synch', null, this);
}
onClick() {
this.env.pos.push_orders(null, { show_error: true });
}
}
SyncNotification.template = 'SyncNotification';
Registries.Component.add(SyncNotification);
return SyncNotification;
});
@@ -0,0 +1,262 @@
odoo.define('point_of_sale.ClassRegistry', function (require) {
'use strict';
/**
* **Usage:**
* ```
* const Registry = new ClassRegistry();
*
* class A {}
* Registry.add(A);
*
* const AExt1 = A => class extends A {}
* Registry.extend(A, AExt1)
*
* const B = A => class extends A {}
* Registry.addByExtending(B, A)
*
* const AExt2 = A => class extends A {}
* Registry.extend(A, AExt2)
*
* Registry.get(A)
* // above returns: AExt2 -> AExt1 -> A
* // Basically, 'A' in the registry points to
* // the inheritance chain above.
*
* Registry.get(B)
* // above returns: B -> AExt2 -> AExt1 -> A
* // Even though B extends A before applying all
* // the extensions of A, when getting it from the
* // registry, the return points to a class with
* // inheritance chain that includes all the extensions
* // of 'A'.
*
* Registry.freeze()
* // Example 'B' above is lazy. Basically, it is only
* // computed when we call `get` from the registry.
* // If we know that no more dynamic inheritances will happen,
* // we can freeze the registry and cache the final form
* // of each class in the registry.
* ```
*
* IMPROVEMENT:
* * So far, mixin can be accomplished by creating a method
* the takes a class and returns a class expression. This is then
* used before the extends keyword like so:
*
* ```js
* class A {}
* Registry.add(A)
* const Mixin = x => class extends x {}
* // apply mixin
* // |
* // v
* const B = x => class extends Mixin(x) {}
* Registry.addByExtending(B, A)
* ```
*
* In the example, `|B| => B -> Mixin -> A`, and this is pretty convenient
* already. However, this can still be improved since classes are only
* compiled after `Registry.freeze()`. Perhaps, we can make the
* Mixins extensible as well, such as so:
*
* ```
* class A {}
* Registry.add(A)
* const Mixin = x => class extends x {}
* Registry.add(Mixin)
* const OtherMixin = x => class extends x {}
* Registry.add(OtherMixin)
* const B = x => class extends x {}
* Registry.addByExtending(B, A, [Mixin, OtherMixin])
* const ExtendMixin = x => class extends x {}
* Registry.extend(Mixin, ExtendMixin)
* ```
*
* In the above, after `Registry.freeze()`,
* `|B| => B -> OtherMixin -> ExtendMixin -> Mixin -> A`
*/
class ClassRegistry {
constructor() {
// base name map
this.baseNameMap = {};
// Object that maps `baseClass` to the class implementation extended in-place.
this.includedMap = new Map();
// Object that maps `baseClassCB` to the array of callbacks to generate the extended class.
this.extendedCBMap = new Map();
// Object that maps `baseClassCB` extended class to the `baseClass` of its super in the includedMap.
this.extendedSuperMap = new Map();
// For faster access, we can `freeze` the registry so that instead of dynamically generating
// the extended classes, it is taken from the cache instead.
this.cache = new Map();
}
/**
* Add a new class in the Registry.
* @param {Function} baseClass `class`
*/
add(baseClass) {
this.includedMap.set(baseClass, []);
this.baseNameMap[baseClass.name] = baseClass;
}
/**
* Add a new class in the Registry based on other class
* in the registry.
* @param {Function} baseClassCB `class -> class`
* @param {Function} base `class | class -> class`
*/
addByExtending(baseClassCB, base) {
this.extendedCBMap.set(baseClassCB, [baseClassCB]);
this.extendedSuperMap.set(baseClassCB, base);
this.baseNameMap[baseClassCB.name] = baseClassCB;
}
/**
* Extend in-place a class in the registry. E.g.
* ```
* // Using the following notation:
* // * |A| - compiled class in the registry
* // * A - class or an extension callback
* // * |A| => A2 -> A1 -> A
* // - the above means, compiled class A
* // points to the class inheritance derived from
* // A2(A1(A))
*
* class A {};
* Registry.add(A);
* // |A| => A
*
* let A1 = x => class extends x {};
* Registry.extend(A, A1);
* // |A| => A1 -> A
*
* let B = x => class extends x {};
* Registry.addByExtending(B, A);
* // |B| => B -> |A|
* // |B| => B -> A1 -> A
*
* let B1 = x => class extends x {};
* Registry.extend(B);
* // |B| => B1 -> B -> |A|
*
* let C = x => class extends x {};
* Registry.addByExtending(C, B);
* // |C| => C -> |B|
*
* let B2 = x => class extends x {};
* Registry.extend(B, B2);
* // |B| => B2 -> B1 -> B -> |A|
*
* // Overall:
* // |A| => A1 -> A
* // |B| => B2 -> B1 -> B -> A1 -> A
* // |C| => C -> B2 -> B1 -> B -> A1 -> A
* ```
* @param {Function} base `class | class -> class`
* @param {Function} extensionCB `class -> class`
*/
extend(base, extensionCB) {
if (typeof base === 'string') {
base = this.baseNameMap[base];
}
let extensionArray;
if (this.includedMap.get(base)) {
extensionArray = this.includedMap.get(base);
} else if (this.extendedCBMap.get(base)) {
extensionArray = this.extendedCBMap.get(base);
} else {
throw new Error(
`'${base.name}' is not in the Registry. Add it to Registry before extending.`
);
}
extensionArray.push(extensionCB);
const locOfNewExtension = extensionArray.length - 1;
const self = this;
const oldCompiled = this.isFrozen ? this.cache.get(base) : null;
return {
remove() {
extensionArray.splice(locOfNewExtension, 1);
self._recompute(base, oldCompiled);
},
compile() {
self._recompute(base);
}
};
}
_compile(base) {
let res;
if (this.includedMap.has(base)) {
res = this.includedMap.get(base).reduce((acc, ext) => ext(acc), base);
} else {
const superClass = this.extendedSuperMap.get(base);
const extensionCBs = this.extendedCBMap.get(base);
res = extensionCBs.reduce((acc, ext) => ext(acc), this._compile(superClass));
}
Object.defineProperty(res, 'name', { value: base.name });
return res;
}
/**
* Return the compiled class (containing all the extensions) of the base class.
* @param {Function} base `class | class -> class` function used in adding the class
*/
get(base) {
if (typeof base === 'string') {
base = this.baseNameMap[base];
}
if (this.isFrozen) {
return this.cache.get(base);
}
return this._compile(base);
}
/**
* Uses the callbacks registered in the registry to compile the classes.
*/
freeze() {
// Step: Compile the `included classes`.
for (let [baseClass, extensionCBs] of this.includedMap.entries()) {
const compiled = extensionCBs.reduce((acc, ext) => ext(acc), baseClass);
this.cache.set(baseClass, compiled);
}
// Step: Compile the `extended classes` based on `included classes`.
// Also gather those the are based on `extended classes`.
const remaining = [];
for (let [baseClassCB, extensionCBArray] of this.extendedCBMap.entries()) {
const compiled = this.cache.get(this.extendedSuperMap.get(baseClassCB));
if (!compiled) {
remaining.push([baseClassCB, extensionCBArray]);
continue;
}
const extendedClass = extensionCBArray.reduce(
(acc, extensionCB) => extensionCB(acc),
compiled
);
this.cache.set(baseClassCB, extendedClass);
}
// Step: Compile the `extended classes` based on `extended classes`.
for (let [baseClassCB, extensionCBArray] of remaining) {
const compiled = this.cache.get(this.extendedSuperMap.get(baseClassCB));
const extendedClass = extensionCBArray.reduce(
(acc, extensionCB) => extensionCB(acc),
compiled
);
this.cache.set(baseClassCB, extendedClass);
}
// Step: Set the name of the compiled classess
for (let [base, compiledClass] of this.cache.entries()) {
Object.defineProperty(compiledClass, 'name', { value: base.name });
}
// Step: Set the flag to true;
this.isFrozen = true;
}
_recompute(base, old) {
if (typeof base === 'string') {
base = this.baseNameMap[base];
}
return old ? old : this._compile(base);
}
}
return ClassRegistry;
});
@@ -0,0 +1,29 @@
odoo.define('point_of_sale.ComponentRegistry', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ClassRegistry = require('point_of_sale.ClassRegistry');
class ComponentRegistry extends ClassRegistry {
freeze() {
super.freeze();
// Make sure PosComponent has the compiled classes.
// This way, we don't need to explicitly declare that
// a set of components is children of another.
PosComponent.components = {};
for (let [base, compiledClass] of this.cache.entries()) {
PosComponent.components[base.name] = compiledClass;
}
}
_recompute(base, old) {
const res = super._recompute(base, old);
if (typeof base === 'string') {
base = this.baseNameMap[base];
}
PosComponent.components[base.name] = res;
return res;
}
}
return ComponentRegistry;
});
+60
View File
@@ -0,0 +1,60 @@
odoo.define('point_of_sale.Gui', function (require) {
'use strict';
/**
* This module bridges the data classes (such as those defined in
* models.js) to the view (owl.Component) but not vice versa.
*
* The idea is to be able to perform side-effects to the user interface
* during calculation. Think of console.log during times we want to see
* the result of calculations. This is no different, except that instead
* of printing something in the console, we access a method in the user
* interface then the user interface reacts, e.g. calling `showPopup`.
*
* This however can be dangerous to the user interface as it can be possible
* that a rendered component is destroyed during the calculation. Because of
* this, we are going to limit external ui controls to those safe ones to
* use such as:
* - `showPopup`
* - `showTempScreen`
*
* IMPROVEMENT: After all, this Gui layer seems to be a good abstraction because
* there is a complete decoupling between data and view despite the data being
* able to use selected functionalities in the view layer. More formalized
* implementation is welcome.
*/
const config = {};
/**
* Call this when the user interface is ready. Provide the component
* that will be used to control the ui.
* @param {owl.component} component component having the ui methods.
*/
const configureGui = ({ component }) => {
config.component = component;
config.availableMethods = new Set([
'showPopup',
'showTempScreen',
'playSound',
'setSyncStatus',
]);
};
/**
* Import this and consume like so: `Gui.showPopup(<PopupName>, <props>)`.
* Like you would call `showPopup` in a component.
*/
const Gui = new Proxy(config, {
get(target, key) {
const { component, availableMethods } = target;
if (!component) throw new Error(`Call 'configureGui' before using Gui.`);
const isMounted = component.__owl__.isMounted;
if (availableMethods.has(key) && isMounted) {
return component[key].bind(component);
}
},
});
return { configureGui, Gui };
});
@@ -0,0 +1,143 @@
odoo.define('point_of_sale.Draggable', function(require) {
'use strict';
const { useExternalListener } = owl.hooks;
const { useListener } = require('web.custom_hooks');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* Wrap an element or a component with { position: absolute } to make it
* draggable around the limitArea or the nearest positioned ancestor.
*
* e.g.
* ```
* <div class="limit-area">
* <Draggable limitArea="'.limit-area'">
* <div class="popup">
* <header class="drag-handle"></header>
* </div>
* <div class="popup body"></div>
* </Draggable>
* </div>
* ```
*
* In the above snippet, if the popup div is { position: absolute },
* then it becomes draggable around the .limit-area element if it is dragged
* thru its Header -- because of the .drag-handle element.
*
* @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }`
*/
class Draggable extends PosComponent {
constructor() {
super(...arguments);
this.isDragging = false;
this.dx = 0;
this.dy = 0;
// drag with mouse
useExternalListener(document, 'mousemove', this.move);
useExternalListener(document, 'mouseup', this.endDrag);
// drag with touch
useExternalListener(document, 'touchmove', this.move);
useExternalListener(document, 'touchend', this.endDrag);
useListener('mousedown', '.drag-handle', this.startDrag);
useListener('touchstart', '.drag-handle', this.startDrag);
}
mounted() {
this.limitArea = this.props.limitArea
? document.querySelector(this.props.limitArea)
: this.el.offsetParent;
this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect();
if (this.limitArea === this.el.offsetParent) {
this.limitLeft = 0;
this.limitTop = 0;
this.limitRight = this.limitAreaBoundingRect.width;
this.limitBottom = this.limitAreaBoundingRect.height;
} else {
this.limitLeft = -this.el.offsetParent.offsetLeft;
this.limitTop = -this.el.offsetParent.offsetTop;
this.limitRight =
this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft;
this.limitBottom =
this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop;
}
this.limitAreaWidth = this.limitAreaBoundingRect.width;
this.limitAreaHeight = this.limitAreaBoundingRect.height;
// absolutely position the element then remove the transform.
const elBoundingRect = this.el.getBoundingClientRect();
this.el.style.top = `${elBoundingRect.top}px`;
this.el.style.left = `${elBoundingRect.left}px`;
this.el.style.transform = 'none';
}
startDrag(event) {
let realEvent;
if (event instanceof CustomEvent) {
realEvent = event.detail;
} else {
realEvent = event;
}
const { x, y } = this._getEventLoc(realEvent);
this.isDragging = true;
this.dx = this.el.offsetLeft - x;
this.dy = this.el.offsetTop - y;
event.stopPropagation();
}
move(event) {
if (this.isDragging) {
const { x: pointerX, y: pointerY } = this._getEventLoc(event);
const posLeft = this._getPosLeft(pointerX, this.dx);
const posTop = this._getPosTop(pointerY, this.dy);
this.el.style.left = `${posLeft}px`;
this.el.style.top = `${posTop}px`;
}
event.preventDefault();
}
endDrag() {
if (this.isDragging) {
this.isDragging = false;
this.trigger('drag-end', {
loc: { top: this.el.offsetTop, left: this.el.offsetLeft },
});
}
}
_getEventLoc(event) {
let coordX, coordY;
if (event.touches && event.touches[0]) {
coordX = event.touches[0].clientX;
coordY = event.touches[0].clientY;
} else {
coordX = event.clientX;
coordY = event.clientY;
}
return {
x: coordX,
y: coordY,
};
}
_getPosLeft(pointerX, dx) {
const posLeft = pointerX + dx;
if (posLeft < this.limitLeft) {
return this.limitLeft;
} else if (posLeft > this.limitRight - this.el.offsetWidth) {
return this.limitRight - this.el.offsetWidth;
}
return posLeft;
}
_getPosTop(pointerY, dy) {
const posTop = pointerY + dy;
if (posTop < this.limitTop) {
return this.limitTop;
} else if (posTop > this.limitBottom - this.el.offsetHeight) {
return this.limitBottom - this.el.offsetHeight;
}
return posTop;
}
}
Draggable.template = 'Draggable';
Registries.Component.add(Draggable);
return Draggable;
});
@@ -0,0 +1,19 @@
odoo.define('point_of_sale.NotificationSound', function (require) {
'use strict';
const { useListener } = require('web.custom_hooks');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class NotificationSound extends PosComponent {
constructor() {
super(...arguments);
useListener('ended', () => (this.props.sound.src = null));
}
}
NotificationSound.template = 'NotificationSound';
Registries.Component.add(NotificationSound);
return NotificationSound;
});
@@ -0,0 +1,277 @@
odoo.define('point_of_sale.NumberBuffer', function(require) {
'use strict';
const { Component, useState } = owl;
const { EventBus } = owl.core;
const { onMounted, onWillUnmount, useExternalListener } = owl.hooks;
const { useListener } = require('web.custom_hooks');
const { parse } = require('web.field_utils');
const { BarcodeEvents } = require('barcodes.BarcodeEvents');
const { _t } = require('web.core');
const { Gui } = require('point_of_sale.Gui');
const INPUT_KEYS = new Set(
['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split(''))
);
const CONTROL_KEYS = new Set(['Enter', 'Esc']);
const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]);
const getDefaultConfig = () => ({
decimalPoint: false,
triggerAtEnter: false,
triggerAtEsc: false,
triggerAtInput: false,
nonKeyboardInputEvent: false,
useWithBarcode: false,
});
/**
* This is a singleton.
*
* Only one component can `use` the buffer at a time.
* This is done by keeping track of each component (and its
* corresponding state and config) using a stack (bufferHolderStack).
* The component on top of the stack is the one that currently
* `holds` the buffer.
*
* When the current component is unmounted, the top of the stack
* is popped and NumberBuffer is set up again for the new component
* on top of the stack.
*
* Usage
* =====
* - Activate in the construction of root component. `NumberBuffer.activate()`
* - Use the buffer in a child component by calling `NumberBuffer.use(<config>)`
* in the constructor of the child component.
* - The component that `uses` the buffer has access to the following instance
* methods of the NumberBuffer:
* - get()
* - set(val)
* - reset()
* - getFloat()
*
* Note
* ====
* - No need to instantiate as it is a single created before exporting in this module.
*
* Possible Improvements
* =====================
* - Relieve the buffer from responsibility of handling `Enter` and other control keys.
* - Make the constants (ALLOWED_KEYS, etc.) more configurable.
*/
class NumberBuffer extends EventBus {
constructor() {
super();
this.isReset = false;
this.bufferHolderStack = [];
}
/**
* @returns {String} value of the buffer, e.g. '-95.79'
*/
get() {
return this.state ? this.state.buffer : null;
}
/**
* Takes a string that is convertible to float, and set it as
* value of the buffer. e.g. val = '2.99';
*
* @param {String} val
*/
set(val) {
this.state.buffer = !isNaN(parseFloat(val)) ? val : '';
this.trigger('buffer-update', this.state.buffer);
}
/**
* Resets the buffer to empty string.
*/
reset() {
this.isReset = true;
this.state.buffer = '';
this.trigger('buffer-update', this.state.buffer);
}
/**
* @returns {number} float equivalent of the value of buffer
*/
getFloat() {
return parse.float(this.get());
}
/**
* Add keyup listener to window via the useExternalListener hook.
* When the component calling this is unmounted, the listener is also
* removed from window.
*/
activate() {
this.defaultDecimalPoint = _t.database.parameters.decimal_point;
useExternalListener(window, 'keyup', this._onKeyboardInput.bind(this));
}
/**
* @param {Object} config Use to setup the buffer
* @param {String|null} config.decimalPoint The decimal character.
* @param {String|null} config.triggerAtEnter Event triggered when 'Enter' key is pressed.
* @param {String|null} config.triggerAtInput Event triggered for every accepted input.
* @param {String|null} config.nonKeyboardInputEvent Also listen to a non-keyboard input event
* that carries a payload of { key }. The key is checked if it is a valid input. If valid,
* the number buffer is modified just as it is modified when a keyboard key is pressed.
* @param {Boolean} config.useWithBarcode Whether this buffer is used with barcode.
*/
use(config) {
this.eventsBuffer = [];
const currentComponent = Component.current;
config = Object.assign(getDefaultConfig(), config);
onMounted(() => {
this.bufferHolderStack.push({
component: currentComponent,
state: config.state ? config.state : useState({ buffer: '' }),
config,
});
this._setUp();
});
onWillUnmount(() => {
this.bufferHolderStack.pop();
this._setUp();
});
// Add listener that accepts non keyboard inputs
if (typeof config.nonKeyboardInputEvent === 'string') {
useListener(config.nonKeyboardInputEvent, this._onNonKeyboardInput.bind(this));
}
}
get _currentBufferHolder() {
return this.bufferHolderStack[this.bufferHolderStack.length - 1];
}
_setUp() {
if (!this._currentBufferHolder) return;
const { component, state, config } = this._currentBufferHolder;
this.component = component;
this.state = state;
this.config = config;
this.decimalPoint = config.decimalPoint || this.defaultDecimalPoint;
this.maxTimeBetweenKeys = this.config.useWithBarcode
? BarcodeEvents.max_time_between_keys_in_ms
: 0;
}
_onKeyboardInput(event) {
return this._bufferEvents(this._onInput(event => event.key))(event);
}
_onNonKeyboardInput(event) {
return this._bufferEvents(this._onInput(event => event.detail.key))(event);
}
_bufferEvents(handler) {
return event => {
if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return;
clearTimeout(this._timeout);
this.eventsBuffer.push(event);
this._timeout = setTimeout(handler, this.maxTimeBetweenKeys);
};
}
_onInput(keyAccessor) {
return () => {
if (this.eventsBuffer.length <= 2) {
// Check first the buffer if its contents are all valid
// number input.
for (let event of this.eventsBuffer) {
if (!ALLOWED_KEYS.has(keyAccessor(event))) {
this.eventsBuffer = [];
return;
}
}
// At this point, all the events in buffer
// contains number input. It's now okay to handle
// each input.
for (let event of this.eventsBuffer) {
this._handleInput(keyAccessor(event));
event.preventDefault();
event.stopPropagation();
}
}
this.eventsBuffer = [];
};
}
_handleInput(key) {
if (key === 'Enter' && this.config.triggerAtEnter) {
this.component.trigger(this.config.triggerAtEnter, this.state);
} else if (key === 'Esc' && this.config.triggerAtEsc) {
this.component.trigger(this.config.triggerAtEsc, this.state);
} else if (INPUT_KEYS.has(key)) {
this._updateBuffer(key);
if (this.config.triggerAtInput)
this.component.trigger(this.config.triggerAtInput, { buffer: this.state.buffer, key });
}
}
/**
* Updates the current buffer state using the given input.
* @param {String} input valid input
*/
_updateBuffer(input) {
const isEmpty = val => {
return val === '' || val === null;
};
if (input === undefined || input === null) return;
let isFirstInput = isEmpty(this.state.buffer);
if (input === this.decimalPoint) {
if (isFirstInput) {
this.state.buffer = '0.';
} else if (!this.state.buffer.length || this.state.buffer === '-') {
this.state.buffer += '0.';
} else if (this.state.buffer.indexOf(this.decimalPoint) < 0) {
this.state.buffer = this.state.buffer + this.decimalPoint;
}
} else if (input === 'Delete') {
if (this.isReset) {
this.state.buffer = '';
this.isReset = false;
return;
}
this.state.buffer = isEmpty(this.state.buffer) ? null : '';
} else if (input === 'Backspace') {
if (this.isReset) {
this.state.buffer = '';
this.isReset = false;
return;
}
const buffer = this.state.buffer;
if (isEmpty(buffer)) {
this.state.buffer = null;
} else {
const nCharToRemove = buffer[buffer.length - 1] === this.decimalPoint ? 2 : 1;
this.state.buffer = buffer.substring(0, buffer.length - nCharToRemove);
}
} else if (input === '+') {
if (this.state.buffer[0] === '-') {
this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length);
}
} else if (input === '-') {
if (isFirstInput) {
this.state.buffer = '-0';
} else if (this.state.buffer[0] === '-') {
this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length);
} else {
this.state.buffer = '-' + this.state.buffer;
}
} else if (input[0] === '+' && !isNaN(parseFloat(input))) {
// when input is like '+10', '+50', etc
const inputValue = parse.float(input.slice(1));
const currentBufferValue = this.state.buffer ? parse.float(this.state.buffer) : 0;
this.state.buffer = this.component.env.pos.formatFixed(
inputValue + currentBufferValue
);
} else if (!isNaN(parseInt(input, 10))) {
if (isFirstInput) {
this.state.buffer = '' + input;
} else if (this.state.buffer.length > 12) {
Gui.playSound('bell');
} else {
this.state.buffer += input;
}
}
if (this.state.buffer === '-') {
this.state.buffer = '';
}
// once an input is accepted and updated the buffer,
// the buffer should not be in reset state anymore.
this.isReset = false;
this.trigger('buffer-update', this.state.buffer);
}
}
return new NumberBuffer();
});
@@ -0,0 +1,44 @@
odoo.define('point_of_sale.PopupControllerMixin', function(require) {
'use strict';
const { useState } = owl;
const { useListener } = require('web.custom_hooks');
/**
* Allows the component declared with this mixin the ability show popup dynamically,
* provided the following:
* 1. The following element is declared in the template. It is where the Popup will be rendered.
* `<t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />`
* 2. The component should trigger `show-popup` event to show the popup and `close-popup` event
* to close. In PosComponent, `showPopup` is conveniently declared to satisfy this requirement.
* @param {Function} x class definition to mix with during extension
*/
const PopupControllerMixin = x =>
class extends x {
constructor() {
super(...arguments);
useListener('show-popup', this.__showPopup);
useListener('close-popup', this.__closePopup);
this.popup = useState({ isShown: false, name: null, component: null });
this.popupProps = {}; // We want to avoid making the props to become Proxy!
}
__showPopup(event) {
const { name, props, resolve } = event.detail;
const popupConstructor = this.constructor.components[name];
if (popupConstructor.dontShow) {
resolve();
return;
}
this.popup.isShown = true;
this.popup.name = name;
this.popup.component = popupConstructor;
this.popupProps = Object.assign({}, props, { resolve });
}
__closePopup() {
this.popup.isShown = false;
}
};
return PopupControllerMixin;
});
@@ -0,0 +1,60 @@
odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) {
'use strict';
const { useExternalListener } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
/**
* Implement this abstract class by extending it like so:
* ```js
* class ConcretePopup extends AbstractAwaitablePopup {
* async getPayload() {
* return 'result';
* }
* }
* ConcretePopup.template = owl.tags.xml`
* <div>
* <button t-on-click="confirm">Okay</button>
* <button t-on-click="cancel">Cancel</button>
* </div>
* `
* ```
*
* The concrete popup can now be instantiated and be awaited for
* the user's response like so:
* ```js
* const { confirmed, payload } = await this.showPopup('ConcretePopup');
* // based on the implementation above,
* // if confirmed, payload = 'result'
* // otherwise, payload = null
* ```
*/
class AbstractAwaitablePopup extends PosComponent {
constructor() {
super(...arguments);
useExternalListener(window, 'keyup', this._cancelAtEscape);
}
async confirm() {
this.props.resolve({ confirmed: true, payload: await this.getPayload() });
this.trigger('close-popup');
}
cancel() {
this.props.resolve({ confirmed: false, payload: null });
this.trigger('close-popup');
}
_cancelAtEscape(event) {
if (event.key === 'Escape') {
this.cancel();
}
}
/**
* Override this in the concrete popup implementation to set the
* payload when the popup is confirmed.
*/
async getPayload() {
return null;
}
}
return AbstractAwaitablePopup;
});
@@ -0,0 +1,20 @@
odoo.define('point_of_sale.ConfirmPopup', function(require) {
'use strict';
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
// formerly ConfirmPopupWidget
class ConfirmPopup extends AbstractAwaitablePopup {}
ConfirmPopup.template = 'ConfirmPopup';
ConfirmPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: 'Confirm ?',
body: '',
};
Registries.Component.add(ConfirmPopup);
return ConfirmPopup;
});
@@ -0,0 +1,19 @@
odoo.define('point_of_sale.EditListInput', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class EditListInput extends PosComponent {
onKeyup(event) {
if (event.key === "Enter" && event.target.value.trim() !== '') {
this.trigger('create-new-item');
}
}
}
EditListInput.template = 'EditListInput';
Registries.Component.add(EditListInput);
return EditListInput;
});
@@ -0,0 +1,105 @@
odoo.define('point_of_sale.EditListPopup', function(require) {
'use strict';
const { useState } = owl.hooks;
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
const { useAutoFocusToLast } = require('point_of_sale.custom_hooks');
/**
* Given a array of { id, text }, we show the user this popup to be able to modify this given array.
* (used to replace PackLotLinePopupWidget)
*
* The expected return of showPopup when this popup is used is an array of { _id, [id], text }.
* - _id is the assigned unique identifier for each item.
* - id is the original id. if not provided, then it means that the item is new.
* - text is the modified/unmodified text.
*
* Example:
*
* ```
* -- perhaps inside a click handler --
* // gather the items to edit
* const names = [{ id: 1, text: 'Joseph'}, { id: 2, text: 'Kaykay' }];
*
* // supply the items to the popup and wait for user's response
* // when user pressed `confirm` in the popup, the changes he made will be returned by the showPopup function.
* const { confirmed, payload: newNames } = await this.showPopup('EditListPopup', {
* title: "Can you confirm this item?",
* array: names })
*
* // we then consume the new data. In this example, it is only logged.
* if (confirmed) {
* console.log(newNames);
* // the above might log the following:
* // [{ _id: 1, id: 1, text: 'Joseph Caburnay' }, { _id: 2, id: 2, 'Kaykay' }, { _id: 3, 'James' }]
* // The result showed that the original item with id=1 was changed to have text 'Joseph Caburnay' from 'Joseph'
* // The one with id=2 did not change. And a new item with text='James' is added.
* }
* ```
*/
class EditListPopup extends AbstractAwaitablePopup {
/**
* @param {String} title required title of popup
* @param {Array} [props.array=[]] the array of { id, text } to be edited or an array of strings
* @param {Boolean} [props.isSingleItem=false] true if only allowed to edit single item (the first item)
*/
constructor() {
super(...arguments);
this._id = 0;
this.state = useState({ array: this._initialize(this.props.array) });
useAutoFocusToLast();
}
_nextId() {
return this._id++;
}
_emptyItem() {
return {
text: '',
_id: this._nextId(),
};
}
_initialize(array) {
// If no array is provided, we initialize with one empty item.
if (array.length === 0) return [this._emptyItem()];
// Put _id for each item. It will serve as unique identifier of each item.
return array.map((item) => Object.assign({}, { _id: this._nextId() }, typeof item === 'object'? item: { 'text': item}));
}
removeItem(event) {
const itemToRemove = event.detail;
this.state.array.splice(
this.state.array.findIndex(item => item._id == itemToRemove._id),
1
);
// We keep a minimum of one empty item in the popup.
if (this.state.array.length === 0) {
this.state.array.push(this._emptyItem());
}
}
createNewItem() {
if (this.props.isSingleItem) return;
this.state.array.push(this._emptyItem());
}
/**
* @override
*/
getPayload() {
return {
newArray: this.state.array
.filter((item) => item.text.trim() !== '')
.map((item) => Object.assign({}, item)),
};
}
}
EditListPopup.template = 'EditListPopup';
EditListPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
array: [],
isSingleItem: false,
};
Registries.Component.add(EditListPopup);
return EditListPopup;
});
@@ -0,0 +1,26 @@
odoo.define('point_of_sale.ErrorBarcodePopup', function(require) {
'use strict';
const ErrorPopup = require('point_of_sale.ErrorPopup');
const Registries = require('point_of_sale.Registries');
// formerly ErrorBarcodePopupWidget
class ErrorBarcodePopup extends ErrorPopup {
get translatedMessage() {
return this.env._t(this.props.message);
}
}
ErrorBarcodePopup.template = 'ErrorBarcodePopup';
ErrorBarcodePopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: 'Error',
body: '',
message:
'The Point of Sale could not find any product, client, employee or action associated with the scanned barcode.',
};
Registries.Component.add(ErrorBarcodePopup);
return ErrorBarcodePopup;
});
@@ -0,0 +1,24 @@
odoo.define('point_of_sale.ErrorPopup', function(require) {
'use strict';
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
// formerly ErrorPopupWidget
class ErrorPopup extends AbstractAwaitablePopup {
mounted() {
this.playSound('error');
}
}
ErrorPopup.template = 'ErrorPopup';
ErrorPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: 'Error',
body: '',
};
Registries.Component.add(ErrorPopup);
return ErrorPopup;
});
@@ -0,0 +1,44 @@
odoo.define('point_of_sale.ErrorTracebackPopup', function(require) {
'use strict';
const ErrorPopup = require('point_of_sale.ErrorPopup');
const Registries = require('point_of_sale.Registries');
// formerly ErrorTracebackPopupWidget
class ErrorTracebackPopup extends ErrorPopup {
get tracebackUrl() {
const blob = new Blob([this.props.body]);
const URL = window.URL || window.webkitURL;
return URL.createObjectURL(blob);
}
get tracebackFilename() {
return `${this.env._t('error')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.txt`;
}
emailTraceback() {
const address = this.env.pos.company.email;
const subject = this.env._t('IMPORTANT: Bug Report From Odoo Point Of Sale');
window.open(
'mailto:' +
address +
'?subject=' +
(subject ? window.encodeURIComponent(subject) : '') +
'&body=' +
(this.props.body ? window.encodeURIComponent(this.props.body) : '')
);
}
}
ErrorTracebackPopup.template = 'ErrorTracebackPopup';
ErrorTracebackPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: 'Error with Traceback',
body: '',
exitButtonIsShown: false,
exitButtonText: 'Exit Pos',
exitButtonTrigger: 'close-pos'
};
Registries.Component.add(ErrorTracebackPopup);
return ErrorTracebackPopup;
});
@@ -0,0 +1,71 @@
odoo.define('point_of_sale.NumberPopup', function(require) {
'use strict';
const { useState } = owl;
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
// formerly NumberPopupWidget
class NumberPopup extends AbstractAwaitablePopup {
/**
* @param {Object} props
* @param {Boolean} props.isPassword Show password popup.
* @param {number|null} props.startingValue Starting value of the popup.
*
* Resolve to { confirmed, payload } when used with showPopup method.
* @confirmed {Boolean}
* @payload {String}
*/
constructor() {
super(...arguments);
useListener('accept-input', this.confirm);
useListener('close-this-popup', this.cancel);
let startingBuffer = '';
if (typeof this.props.startingValue === 'number' && this.props.startingValue > 0) {
startingBuffer = this.props.startingValue.toString();
}
this.state = useState({ buffer: startingBuffer });
NumberBuffer.use({
nonKeyboardInputEvent: 'numpad-click-input',
triggerAtEnter: 'accept-input',
triggerAtEscape: 'close-this-popup',
state: this.state,
});
}
get decimalSeparator() {
return this.env._t.database.parameters.decimal_point;
}
get inputBuffer() {
if (this.state.buffer === null) {
return '';
}
if (this.props.isPassword) {
return this.state.buffer.replace(/./g, '•');
} else {
return this.state.buffer;
}
}
sendInput(key) {
this.trigger('numpad-click-input', { key });
}
getPayload() {
return NumberBuffer.get();
}
}
NumberPopup.template = 'NumberPopup';
NumberPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: 'Confirm ?',
body: '',
cheap: false,
startingValue: null,
isPassword: false,
};
Registries.Component.add(NumberPopup);
return NumberPopup;
});
@@ -0,0 +1,29 @@
odoo.define('point_of_sale.OfflineErrorPopup', function(require) {
'use strict';
const ErrorPopup = require('point_of_sale.ErrorPopup');
const Registries = require('point_of_sale.Registries');
/**
* This is a special kind of error popup as it introduces
* an option to not show it again.
*/
class OfflineErrorPopup extends ErrorPopup {
dontShowAgain() {
this.constructor.dontShow = true;
this.cancel();
}
}
OfflineErrorPopup.template = 'OfflineErrorPopup';
OfflineErrorPopup.dontShow = false;
OfflineErrorPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: 'Offline Error',
body: 'Either the server is inaccessible or browser is not connected online.',
};
Registries.Component.add(OfflineErrorPopup);
return OfflineErrorPopup;
});
@@ -0,0 +1,27 @@
odoo.define('point_of_sale.OrderImportPopup', function(require) {
'use strict';
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
// formerly OrderImportPopupWidget
class OrderImportPopup extends AbstractAwaitablePopup {
get unpaidSkipped() {
return (
(this.props.report.unpaid_skipped_existing || 0) +
(this.props.report.unpaid_skipped_session || 0)
);
}
getPayload() {}
}
OrderImportPopup.template = 'OrderImportPopup';
OrderImportPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
body: '',
};
Registries.Component.add(OrderImportPopup);
return OrderImportPopup;
});
@@ -0,0 +1,63 @@
odoo.define('point_of_sale.SelectionPopup', function(require) {
'use strict';
const { useState } = owl.hooks;
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
// formerly SelectionPopupWidget
class SelectionPopup extends AbstractAwaitablePopup {
/**
* Value of the `item` key of the selected element in the Selection
* Array is the payload of this popup.
*
* @param {Object} props
* @param {String} [props.confirmText='Confirm']
* @param {String} [props.cancelText='Cancel']
* @param {String} [props.title='Select']
* @param {String} [props.body='']
* @param {Array<Selection>} [props.list=[]]
* Selection {
* id: integer,
* label: string,
* isSelected: boolean,
* item: any,
* }
*/
constructor() {
super(...arguments);
this.list = useState([...this.props.list]);
}
selectItem(itemId) {
for (let item of this.list) {
if (item.id === itemId) {
item.isSelected = true;
} else {
item.isSelected = false;
}
}
this.confirm();
}
/**
* We send as payload of the response the selected item.
*
* @override
*/
getPayload() {
const selected = this.props.list.find(item => item.isSelected);
return selected && selected.item;
}
}
SelectionPopup.template = 'SelectionPopup';
SelectionPopup.defaultProps = {
confirmText: 'Confirm',
cancelText: 'Cancel',
title: 'Select',
body: '',
list: [],
};
Registries.Component.add(SelectionPopup);
return SelectionPopup;
});
@@ -0,0 +1,39 @@
odoo.define('point_of_sale.TextAreaPopup', function(require) {
'use strict';
const { useState, useRef } = owl.hooks;
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
// formerly TextAreaPopupWidget
// IMPROVEMENT: This code is very similar to TextInputPopup.
// Combining them would reduce the code.
class TextAreaPopup extends AbstractAwaitablePopup {
/**
* @param {Object} props
* @param {string} props.startingValue
*/
constructor() {
super(...arguments);
this.state = useState({ inputValue: this.props.startingValue });
this.inputRef = useRef('input');
}
mounted() {
this.inputRef.el.focus();
}
getPayload() {
return this.state.inputValue;
}
}
TextAreaPopup.template = 'TextAreaPopup';
TextAreaPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: '',
body: '',
};
Registries.Component.add(TextAreaPopup);
return TextAreaPopup;
});
@@ -0,0 +1,34 @@
odoo.define('point_of_sale.TextInputPopup', function(require) {
'use strict';
const { useState, useRef } = owl.hooks;
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
// formerly TextInputPopupWidget
class TextInputPopup extends AbstractAwaitablePopup {
constructor() {
super(...arguments);
this.state = useState({ inputValue: this.props.startingValue });
this.inputRef = useRef('input');
}
mounted() {
this.inputRef.el.focus();
}
getPayload() {
return this.state.inputValue;
}
}
TextInputPopup.template = 'TextInputPopup';
TextInputPopup.defaultProps = {
confirmText: 'Ok',
cancelText: 'Cancel',
title: '',
body: '',
startingValue: '',
};
Registries.Component.add(TextInputPopup);
return TextInputPopup;
});
@@ -0,0 +1,59 @@
odoo.define('point_of_sale.PosComponent', function (require) {
'use strict';
const { Component } = owl;
class PosComponent extends Component {
/**
* This function is available to all Components that inherit this class.
* The goal of this function is to show an awaitable dialog (popup) that
* returns a response after user interaction. See the following for quick
* demonstration:
*
* ```
* async getUserName() {
* const userResponse = await this.showPopup(
* 'TextInputPopup',
* { title: 'What is your name?' }
* );
* // at this point, the TextInputPopup is displayed. Depending on how the popup is defined,
* // say the input contains the name, the result of the interaction with the user is
* // saved in `userResponse`.
* console.log(userResponse); // logs { confirmed: true, payload: <name> }
* }
* ```
*
* @param {String} name Name of the popup component
* @param {Object} props Object that will be used to render to popup
*/
showPopup(name, props) {
return new Promise((resolve) => {
this.trigger('show-popup', { name, props, resolve });
});
}
showTempScreen(name, props) {
return new Promise((resolve) => {
this.trigger('show-temp-screen', { name, props, resolve });
});
}
showScreen(name, props) {
this.trigger('show-main-screen', { name, props });
}
/**
* @param {String} name 'bell' | 'error'
*/
playSound(name) {
this.trigger('play-sound', name);
}
/**
* Control the SyncNotification component.
* @param {String} status 'connected' | 'connecting' | 'disconnected' | 'error'
* @param {String} pending number of pending orders to sync
*/
setSyncStatus(status, pending) {
this.trigger('set-sync-status', { status, pending });
}
}
return PosComponent;
});
@@ -0,0 +1,11 @@
odoo.define('point_of_sale.Registries', function(require) {
'use strict';
/**
* This definition contains all the instances of ClassRegistry.
*/
const ComponentRegistry = require('point_of_sale.ComponentRegistry');
return { Component: new ComponentRegistry() };
});
@@ -0,0 +1,22 @@
odoo.define('point_of_sale.ClientDetails', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class ClientDetails extends PosComponent {
get partnerImageUrl() {
const partner = this.props.partner;
if (partner) {
return `/web/image?model=res.partner&id=${partner.id}&field=image_128&write_date=${partner.write_date}&unique=1`;
} else {
return false;
}
}
}
ClientDetails.template = 'ClientDetails';
Registries.Component.add(ClientDetails);
return ClientDetails;
});
@@ -0,0 +1,112 @@
odoo.define('point_of_sale.ClientDetailsEdit', function(require) {
'use strict';
const { getDataURLFromFile } = require('web.utils');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class ClientDetailsEdit extends PosComponent {
constructor() {
super(...arguments);
this.intFields = ['country_id', 'state_id', 'property_product_pricelist'];
this.changes = {};
}
get partnerImageUrl() {
// We prioritize image_1920 in the `changes` field because we want
// to show the uploaded image without fetching new data from the server.
const partner = this.props.partner;
if (this.changes.image_1920) {
return this.changes.image_1920;
} else if (partner.id) {
return `/web/image?model=res.partner&id=${partner.id}&field=image_128&write_date=${partner.write_date}&unique=1`;
} else {
return false;
}
}
/**
* Save to field `changes` all input changes from the form fields.
*/
captureChange(event) {
this.changes[event.target.name] = event.target.value;
}
saveChanges() {
let processedChanges = {};
for (let [key, value] of Object.entries(this.changes)) {
if (this.intFields.includes(key)) {
processedChanges[key] = parseInt(value) || false;
} else {
processedChanges[key] = value;
}
}
processedChanges.id = this.props.partner.id || false;
this.trigger('save-changes', { processedChanges });
}
async uploadImage(event) {
const file = event.target.files[0];
if (!file.type.match(/image.*/)) {
await this.showPopup('ErrorPopup', {
title: this.env._t('Unsupported File Format'),
body: this.env._t(
'Only web-compatible Image formats such as .png or .jpeg are supported.'
),
});
} else {
const imageUrl = await getDataURLFromFile(file);
const loadedImage = await this._loadImage(imageUrl);
if (loadedImage) {
const resizedImage = await this._resizeImage(loadedImage, 800, 600);
this.changes.image_1920 = resizedImage.toDataURL();
// Rerender to reflect the changes in the screen
this.render();
}
}
}
_resizeImage(img, maxwidth, maxheight) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
var ratio = 1;
if (img.width > maxwidth) {
ratio = maxwidth / img.width;
}
if (img.height * ratio > maxheight) {
ratio = maxheight / img.height;
}
var width = Math.floor(img.width * ratio);
var height = Math.floor(img.height * ratio);
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
return canvas;
}
/**
* Loading image is converted to a Promise to allow await when
* loading an image. It resolves to the loaded image if succesful,
* else, resolves to false.
*
* [Source](https://stackoverflow.com/questions/45788934/how-to-turn-this-callback-into-a-promise-using-async-await)
*/
_loadImage(url) {
return new Promise((resolve) => {
const img = new Image();
img.addEventListener('load', () => resolve(img));
img.addEventListener('error', () => {
this.showPopup('ErrorPopup', {
title: this.env._t('Loading Image Error'),
body: this.env._t(
'Encountered error when loading image. Please try again.'
),
});
resolve(false);
});
img.src = url;
});
}
}
ClientDetailsEdit.template = 'ClientDetailsEdit';
Registries.Component.add(ClientDetailsEdit);
return ClientDetailsEdit;
});
@@ -0,0 +1,21 @@
odoo.define('point_of_sale.ClientLine', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class ClientLine extends PosComponent {
get highlight() {
if (this.props.partner !== this.props.selectedClient) {
return '';
} else {
return this.props.detailIsShown ? 'highlight' : 'lowlight';
}
}
}
ClientLine.template = 'ClientLine';
Registries.Component.add(ClientLine);
return ClientLine;
});
@@ -0,0 +1,167 @@
odoo.define('point_of_sale.ClientListScreen', function(require) {
'use strict';
const { debounce } = owl.utils;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* Render this screen using `showTempScreen` to select client.
* When the shown screen is confirmed ('Set Customer' or 'Deselect Customer'
* button is clicked), the call to `showTempScreen` resolves to the
* selected client. E.g.
*
* ```js
* const { confirmed, payload: selectedClient } = await showTempScreen('ClientListScreen');
* if (confirmed) {
* // do something with the selectedClient
* }
* ```
*
* @props client - originally selected client
*/
class ClientListScreen extends PosComponent {
constructor() {
super(...arguments);
// We are not using useState here because the object
// passed to useState converts the object and its contents
// to Observer proxy. Not sure of the side-effects of making
// a persistent object, such as pos, into owl.Observer. But it
// is better to be safe.
this.state = {
query: null,
selectedClient: this.props.client,
detailIsShown: Boolean(this.props.client),
isEditMode: false,
editModeProps: {
partner: {
country_id: this.env.pos.company.country_id,
state_id: this.env.pos.company.state_id,
}
},
};
this.updateClientList = debounce(this.updateClientList, 70);
}
// Lifecycle hooks
back() {
this.props.resolve({ confirmed: false, payload: false });
this.trigger('close-temp-screen');
}
confirm() {
this.props.resolve({ confirmed: true, payload: this.state.selectedClient });
this.trigger('close-temp-screen');
}
// Getters
get currentOrder() {
return this.env.pos.get_order();
}
get clients() {
if (this.state.query && this.state.query.trim() !== '') {
return this.env.pos.db.search_partner(this.state.query.trim());
} else {
return this.env.pos.db.get_partners_sorted(1000);
}
}
get isNextButtonVisible() {
return this.state.selectedClient ? !this.state.isEditMode : false;
}
/**
* Returns the text and command of the next button.
* The command field is used by the clickNext call.
*/
get nextButton() {
if (!this.props.client) {
return { command: 'set', text: 'Set Customer' };
} else if (
this.props.client &&
this.props.client === this.state.selectedClient
) {
return { command: 'deselect', text: 'Deselect Customer' };
} else {
return { command: 'set', text: 'Change Customer' };
}
}
// Methods
// We declare this event handler as a debounce function in
// order to lower its trigger rate.
updateClientList(event) {
this.state.query = event.target.value;
const clients = this.clients;
if (event.code === 'Enter' && clients.length === 1) {
this.state.selectedClient = clients[0];
this.clickNext();
} else {
this.render();
}
}
clickClient(event) {
let partner = event.detail.client;
if (this.state.selectedClient === partner) {
this.state.detailIsShown = !this.state.detailIsShown;
} else {
this.state.selectedClient = partner;
this.state.detailIsShown = true;
}
this.deactivateEditMode();
}
clickNext() {
this.state.selectedClient = this.nextButton.command === 'set' ? this.state.selectedClient : null;
this.confirm();
}
activateEditMode(event) {
const { isNewClient } = event.detail;
this.state.isEditMode = true;
this.state.detailIsShown = true;
if (!isNewClient) {
this.state.editModeProps = {
partner: this.state.selectedClient,
};
}
this.render();
}
deactivateEditMode() {
this.state.isEditMode = false;
this.state.editModeProps = {
partner: {
country_id: this.env.pos.company.country_id,
state_id: this.env.pos.company.state_id,
},
};
this.render();
}
async saveChanges(event) {
try {
let partnerId = await this.rpc({
model: 'res.partner',
method: 'create_from_ui',
args: [event.detail.processedChanges],
});
await this.env.pos.load_new_partners();
this.state.selectedClient = this.env.pos.db.get_partner_by_id(partnerId);
this.deactivateEditMode();
} catch (error) {
if (error.message.code < 0) {
await this.showPopup('OfflineErrorPopup', {
title: this.env._t('Offline'),
body: this.env._t('Unable to save changes.'),
});
} else {
throw error;
}
}
}
cancelEdit() {
this.deactivateEditMode();
}
}
ClientListScreen.template = 'ClientListScreen';
Registries.Component.add(ClientListScreen);
return ClientListScreen;
});
@@ -0,0 +1,17 @@
odoo.define('point_of_sale.PSNumpadInputButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class PSNumpadInputButton extends PosComponent {
get _class() {
return this.props.changeClassTo || 'input-button number-char';
}
}
PSNumpadInputButton.template = 'PSNumpadInputButton';
Registries.Component.add(PSNumpadInputButton);
return PSNumpadInputButton;
});
@@ -0,0 +1,13 @@
odoo.define('point_of_sale.PaymentMethodButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class PaymentMethodButton extends PosComponent {}
PaymentMethodButton.template = 'PaymentMethodButton';
Registries.Component.add(PaymentMethodButton);
return PaymentMethodButton;
});
@@ -0,0 +1,422 @@
odoo.define('point_of_sale.PaymentScreen', function (require) {
'use strict';
const { parse } = require('web.field_utils');
const { is_email } = require('web.utils');
const PosComponent = require('point_of_sale.PosComponent');
const { useErrorHandlers } = require('point_of_sale.custom_hooks');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const { useListener } = require('web.custom_hooks');
const OrderReceipt = require('point_of_sale.OrderReceipt');
const { Printer } = require('point_of_sale.Printer');
const Registries = require('point_of_sale.Registries');
const { onChangeOrder } = require('point_of_sale.custom_hooks');
class PaymentScreen extends PosComponent {
constructor() {
super(...arguments);
useListener('delete-payment-line', this.deletePaymentLine);
useListener('select-payment-line', this.selectPaymentLine);
useListener('new-payment-line', this.addNewPaymentLine);
useListener('update-selected-paymentline', this._updateSelectedPaymentline);
useListener('send-payment-request', this._sendPaymentRequest);
useListener('send-payment-cancel', this._sendPaymentCancel);
useListener('send-payment-reverse', this._sendPaymentReverse);
useListener('send-force-done', this._sendForceDone);
onChangeOrder(this._onPrevOrder, this._onNewOrder);
useErrorHandlers();
NumberBuffer.use({
// The numberBuffer listens to this event to update its state.
// Basically means 'update the buffer when this event is triggered'
nonKeyboardInputEvent: 'input-from-numpad',
// When the buffer is updated, trigger this event.
// Note that the component listens to it.
triggerAtInput: 'update-selected-paymentline',
});
this.payment_interface = null;
}
get currentOrder() {
return this.env.pos.get_order();
}
get paymentLines() {
return this.currentOrder.get_paymentlines();
}
get selectedPaymentLine() {
return this.currentOrder.selected_paymentline;
}
async selectClient() {
// IMPROVEMENT: This code snippet is repeated multiple times.
// Maybe it's better to create a function for it.
const currentClient = this.currentOrder.get_client();
const { confirmed, payload: newClient } = await this.showTempScreen(
'ClientListScreen',
{ client: currentClient }
);
if (confirmed) {
this.currentOrder.set_client(newClient);
this.currentOrder.updatePricelist(newClient);
}
}
addNewPaymentLine({ detail: paymentMethod }) {
// original function: click_paymentmethods
if (this.currentOrder.electronic_payment_in_progress()) {
this.showPopup('ErrorPopup', {
title: this.env._t('Error'),
body: this.env._t('There is already an electronic payment in progress.'),
});
return false;
} else {
this.currentOrder.add_paymentline(paymentMethod);
NumberBuffer.reset();
this.payment_interface = paymentMethod.payment_terminal;
if (this.payment_interface) {
this.currentOrder.selected_paymentline.set_payment_status('pending');
}
return true;
}
}
_updateSelectedPaymentline() {
if (this.paymentLines.every((line) => line.paid)) {
this.currentOrder.add_paymentline(this.env.pos.payment_methods[0]);
}
if (!this.selectedPaymentLine) return; // do nothing if no selected payment line
// disable changing amount on paymentlines with running or done payments on a payment terminal
if (
this.payment_interface &&
!['pending', 'retry'].includes(this.selectedPaymentLine.get_payment_status())
) {
return;
}
if (NumberBuffer.get() === null) {
this.deletePaymentLine({ detail: { cid: this.selectedPaymentLine.cid } });
} else {
this.selectedPaymentLine.set_amount(NumberBuffer.getFloat());
}
}
toggleIsToInvoice() {
// click_invoice
this.currentOrder.set_to_invoice(!this.currentOrder.is_to_invoice());
this.render();
}
toggleIsToEmail() {
// click_email
this.currentOrder.set_to_email(!this.currentOrder.is_to_email());
this.render();
}
openCashbox() {
this.env.pos.proxy.printer.open_cashbox();
}
async addTip() {
// click_tip
const tip = this.currentOrder.get_tip();
const change = this.currentOrder.get_change();
let value = tip.toFixed(this.env.pos.decimals);
if (tip === 0 && change > 0) {
value = change;
}
const { confirmed, payload } = await this.showPopup('NumberPopup', {
title: tip ? this.env._t('Change Tip') : this.env._t('Add Tip'),
startingValue: value,
});
if (confirmed) {
this.currentOrder.set_tip(parse.float(payload));
}
}
deletePaymentLine(event) {
const { cid } = event.detail;
const line = this.paymentLines.find((line) => line.cid === cid);
// If a paymentline with a payment terminal linked to
// it is removed, the terminal should get a cancel
// request.
if (['waiting', 'waitingCard', 'timeout'].includes(line.get_payment_status())) {
line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid);
}
this.currentOrder.remove_paymentline(line);
NumberBuffer.reset();
this.render();
}
selectPaymentLine(event) {
const { cid } = event.detail;
const line = this.paymentLines.find((line) => line.cid === cid);
this.currentOrder.select_paymentline(line);
NumberBuffer.reset();
this.render();
}
async validateOrder(isForceValidate) {
if (await this._isOrderValid(isForceValidate)) {
// remove pending payments before finalizing the validation
for (let line of this.paymentLines) {
if (!line.is_done()) this.currentOrder.remove_paymentline(line);
}
await this._finalizeValidation();
}
}
async _finalizeValidation() {
if (this.currentOrder.is_paid_with_cash() && this.env.pos.config.iface_cashdrawer) {
this.env.pos.proxy.printer.open_cashbox();
}
this.currentOrder.initialize_validation_date();
this.currentOrder.finalized = true;
let syncedOrderBackendIds = [];
let errorCode;
try {
if (this.currentOrder.is_to_invoice()) {
syncedOrderBackendIds = await this.env.pos.push_and_invoice_order(
this.currentOrder
);
} else {
syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder);
}
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
errorCode = error.code;
await this._handlePushOrderError(error);
}
}
if (syncedOrderBackendIds.length && this.currentOrder.wait_for_push_order()) {
const result = await this._postPushOrderResolve(
this.currentOrder,
syncedOrderBackendIds
);
if (!result) {
await this.showPopup('ErrorPopup', {
title: 'Error: no internet connection.',
body: error,
});
}
}
const shouldShowPrintInvoice = errorCode
? this.currentOrder.is_to_invoice() && errorCode < 0
: false;
this.showScreen('ReceiptScreen', { printInvoiceIsShown: shouldShowPrintInvoice });
// If we succeeded in syncing the current order, and
// there are still other orders that are left unsynced,
// we ask the user if he is willing to wait and sync them.
if (syncedOrderBackendIds.length && this.env.pos.db.get_orders().length) {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Remaining unsynced orders'),
body: this.env._t(
'There are unsynced orders. Do you want to sync these orders?'
),
});
if (confirmed) {
// NOTE: Not yet sure if this should be awaited or not.
// If awaited, some operations like changing screen
// might not work.
this.env.pos.push_orders();
}
}
}
async _isOrderValid(isForceValidate) {
if (this.currentOrder.get_orderlines().length === 0) {
this.showPopup('ErrorPopup', {
title: this.env._t('Empty Order'),
body: this.env._t(
'There must be at least one product in your order before it can be validated'
),
});
return false;
}
if (this.currentOrder.is_to_invoice() && !this.currentOrder.get_client()) {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Please select the Customer'),
body: this.env._t(
'You need to select the customer before you can invoice an order.'
),
});
if (confirmed) {
this.selectClient();
}
return false;
}
if (!this.currentOrder.is_paid() || this.invoicing) {
return false;
}
if (this.currentOrder.has_not_valid_rounding()) {
var line = this.currentOrder.has_not_valid_rounding();
this.showPopup('ErrorPopup', {
title: this.env._t('Incorrect rounding'),
body: this.env._t(
'You have to round your payments lines.' + line.amount + ' is not rounded.'
),
});
return false;
}
// The exact amount must be paid if there is no cash payment method defined.
if (
Math.abs(
this.currentOrder.get_total_with_tax() - this.currentOrder.get_total_paid() + this.currentOrder.get_rounding_applied()
) > 0.00001
) {
var cash = false;
for (var i = 0; i < this.env.pos.payment_methods.length; i++) {
cash = cash || this.env.pos.payment_methods[i].is_cash_count;
}
if (!cash) {
this.showPopup('ErrorPopup', {
title: this.env._t('Cannot return change without a cash payment method'),
body: this.env._t(
'There is no cash payment method available in this point of sale to handle the change.\n\n Please pay the exact amount or add a cash payment method in the point of sale configuration'
),
});
return false;
}
}
var client = this.currentOrder.get_client();
if (
this.currentOrder.is_to_email() &&
(!client || (client && !is_email(client.email)))
) {
var title = !client ? 'Please select the customer' : 'Please provide valid email';
var body = !client
? 'You need to select the customer before you can send the receipt via email.'
: 'This customer does not have a valid email address, define one or do not send an email.';
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t(title),
body: this.env._t(body),
});
if (confirmed) {
this.selectClient();
}
return false;
}
// if the change is too large, it's probably an input error, make the user confirm.
if (
!isForceValidate &&
this.currentOrder.get_total_with_tax() > 0 &&
this.currentOrder.get_total_with_tax() * 1000 < this.currentOrder.get_total_paid()
) {
this.showPopup('ConfirmPopup', {
title: this.env._t('Please Confirm Large Amount'),
body:
this.env._t('Are you sure that the customer wants to pay') +
' ' +
this.env.pos.format_currency(this.currentOrder.get_total_paid()) +
' ' +
this.env._t('for an order of') +
' ' +
this.env.pos.format_currency(this.currentOrder.get_total_with_tax()) +
' ' +
this.env._t('? Clicking "Confirm" will validate the payment.'),
}).then(({ confirmed }) => {
if (confirmed) this.validateOrder(true);
});
return false;
}
return true;
}
async _postPushOrderResolve(order, order_server_ids) {
if (order.is_to_email()) {
return await this._sendReceiptToCustomer(order_server_ids);
} else {
return true;
}
}
async _sendReceiptToCustomer(order_server_ids) {
const order = this.currentOrder;
const fixture = document.createElement('div');
const orderReceipt = new OrderReceipt(this, { order });
// Important to mount the component to a HTMLElement.
// If not properly mounted, HTMLElement (el) corresponding
// the component is not created.
await orderReceipt.mount(fixture);
const receiptString = orderReceipt.el.outerHTML;
fixture.remove();
const printer = new Printer();
const ticketImage = await printer.htmlToImg(receiptString);
const orderName = order.get_name();
const orderClient = order.get_client();
try {
await this.rpc({
model: 'pos.order',
method: 'action_receipt_to_customer',
args: [order_server_ids, orderName, orderClient, ticketImage],
});
} catch (error) {
order.set_to_email(false);
return false;
}
return true;
}
async _sendPaymentRequest({ detail: line }) {
// Other payment lines can not be reversed anymore
this.paymentLines.forEach(function (line) {
line.can_be_reversed = false;
});
const payment_terminal = line.payment_method.payment_terminal;
line.set_payment_status('waiting');
const isPaymentSuccessful = await payment_terminal.send_payment_request(line.cid);
if (isPaymentSuccessful) {
line.set_payment_status('done');
line.can_be_reversed = this.payment_interface.supports_reversals;
} else {
line.set_payment_status('retry');
}
}
async _sendPaymentCancel({ detail: line }) {
const payment_terminal = line.payment_method.payment_terminal;
line.set_payment_status('waitingCancel');
try {
payment_terminal.send_payment_cancel(this.currentOrder, line.cid);
} finally {
line.set_payment_status('retry');
}
}
async _sendPaymentReverse({ detail: line }) {
const payment_terminal = line.payment_method.payment_terminal;
line.set_payment_status('reversing');
const isReversalSuccessful = await payment_terminal.send_payment_reversal(line.cid);
if (isReversalSuccessful) {
line.set_amount(0);
line.set_payment_status('reversed');
} else {
line.can_be_reversed = false;
line.set_payment_status('done');
}
}
async _sendForceDone({ detail: line }) {
line.set_payment_status('done');
}
_onPrevOrder(prevOrder) {
prevOrder.off('change', null, this);
prevOrder.paymentlines.off('change', null, this);
if (prevOrder) {
prevOrder.stop_electronic_payment();
}
}
async _onNewOrder(newOrder) {
newOrder.on('change', this.render, this);
newOrder.paymentlines.on('change', this.render, this);
NumberBuffer.reset();
await this.render();
}
}
PaymentScreen.template = 'PaymentScreen';
Registries.Component.add(PaymentScreen);
return PaymentScreen;
});
@@ -0,0 +1,23 @@
odoo.define('point_of_sale.PaymentScreenElectronicPayment', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class PaymentScreenElectronicPayment extends PosComponent {
mounted() {
this.props.line.on('change', this.render, this);
}
willUnmount() {
if (this.props.line) {
// It could be that the line is deleted before unmounting the element.
this.props.line.off('change', null, this);
}
}
}
PaymentScreenElectronicPayment.template = 'PaymentScreenElectronicPayment';
Registries.Component.add(PaymentScreenElectronicPayment);
return PaymentScreenElectronicPayment;
});
@@ -0,0 +1,18 @@
odoo.define('point_of_sale.PaymentScreenNumpad', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class PaymentScreenNumpad extends PosComponent {
constructor() {
super(...arguments);
this.decimalPoint = this.env._t.database.parameters.decimal_point;
}
}
PaymentScreenNumpad.template = 'PaymentScreenNumpad';
Registries.Component.add(PaymentScreenNumpad);
return PaymentScreenNumpad;
});
@@ -0,0 +1,39 @@
odoo.define('point_of_sale.PaymentScreenPaymentLines', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class PaymentScreenPaymentLines extends PosComponent {
formatLineAmount(paymentline) {
return this.env.pos.format_currency_no_symbol(paymentline.get_amount());
}
get changeText() {
return this.env.pos.format_currency(this.currentOrder.get_change());
}
get totalDueText() {
return this.env.pos.format_currency(
this.currentOrder.get_total_with_tax() + this.currentOrder.get_rounding_applied()
);
}
get remainingText() {
return this.env.pos.format_currency(
this.currentOrder.get_due() > 0 ? this.currentOrder.get_due() : 0
);
}
get currentOrder() {
return this.env.pos.get_order();
}
selectedLineClass(line) {
return { 'payment-terminal': line.get_payment_status() };
}
unselectedLineClass(line) {
return {};
}
}
PaymentScreenPaymentLines.template = 'PaymentScreenPaymentLines';
Registries.Component.add(PaymentScreenPaymentLines);
return PaymentScreenPaymentLines;
});
@@ -0,0 +1,25 @@
odoo.define('point_of_sale.ActionpadWidget', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* @props client
* @trigger click-customer
* @trigger click-pay
*/
class ActionpadWidget extends PosComponent {
get isLongName() {
return this.client && this.client.name.length > 10;
}
get client() {
return this.props.client;
}
}
ActionpadWidget.template = 'ActionpadWidget';
Registries.Component.add(ActionpadWidget);
return ActionpadWidget;
});
@@ -0,0 +1,13 @@
odoo.define('point_of_sale.CategoryBreadcrumb', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class CategoryBreadcrumb extends PosComponent {}
CategoryBreadcrumb.template = 'CategoryBreadcrumb';
Registries.Component.add(CategoryBreadcrumb);
return CategoryBreadcrumb;
});
@@ -0,0 +1,18 @@
odoo.define('point_of_sale.CategoryButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class CategoryButton extends PosComponent {
get imageUrl() {
const category = this.props.category
return `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`;
}
}
CategoryButton.template = 'CategoryButton';
Registries.Component.add(CategoryButton);
return CategoryButton;
});
@@ -0,0 +1,13 @@
odoo.define('point_of_sale.CategorySimpleButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class CategorySimpleButton extends PosComponent {}
CategorySimpleButton.template = 'CategorySimpleButton';
Registries.Component.add(CategorySimpleButton);
return CategorySimpleButton;
});
@@ -0,0 +1,80 @@
odoo.define('point_of_sale.SetFiscalPositionButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ProductScreen = require('point_of_sale.ProductScreen');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
class SetFiscalPositionButton extends PosComponent {
constructor() {
super(...arguments);
useListener('click', this.onClick);
}
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
}
willUnmount() {
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
}
get currentOrder() {
return this.env.pos.get_order();
}
get currentFiscalPositionName() {
return this.currentOrder && this.currentOrder.fiscal_position
? this.currentOrder.fiscal_position.display_name
: this.env._t('Tax');
}
async onClick() {
const currentFiscalPosition = this.currentOrder.fiscal_position;
const fiscalPosList = [
{
id: -1,
label: this.env._t('None'),
isSelected: !currentFiscalPosition,
},
];
for (let fiscalPos of this.env.pos.fiscal_positions) {
fiscalPosList.push({
id: fiscalPos.id,
label: fiscalPos.name,
isSelected: currentFiscalPosition
? fiscalPos.id === currentFiscalPosition.id
: false,
item: fiscalPos,
});
}
const { confirmed, payload: selectedFiscalPosition } = await this.showPopup(
'SelectionPopup',
{
title: this.env._t('Select Fiscal Position'),
list: fiscalPosList,
}
);
if (confirmed) {
this.currentOrder.fiscal_position = selectedFiscalPosition;
// IMPROVEMENT: The following is the old implementation and I believe
// there could be a better way of doing it.
for (let line of this.currentOrder.orderlines.models) {
line.set_quantity(line.quantity);
}
this.currentOrder.trigger('change');
}
}
}
SetFiscalPositionButton.template = 'SetFiscalPositionButton';
ProductScreen.addControlButton({
component: SetFiscalPositionButton,
condition: function() {
return this.env.pos.fiscal_positions.length > 0;
},
position: ['before', 'SetPricelistButton'],
});
Registries.Component.add(SetFiscalPositionButton);
return SetFiscalPositionButton;
});
@@ -0,0 +1,67 @@
odoo.define('point_of_sale.SetPricelistButton', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ProductScreen = require('point_of_sale.ProductScreen');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
class SetPricelistButton extends PosComponent {
constructor() {
super(...arguments);
useListener('click', this.onClick);
}
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
}
willUnmount() {
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
}
get currentOrder() {
return this.env.pos.get_order();
}
get currentPricelistName() {
const order = this.currentOrder;
return order && order.pricelist
? order.pricelist.display_name
: this.env._t('Pricelist');
}
async onClick() {
// Create the list to be passed to the SelectionPopup.
// Pricelist object is passed as item in the list because it
// is the object that will be returned when the popup is confirmed.
const selectionList = this.env.pos.pricelists.map(pricelist => ({
id: pricelist.id,
label: pricelist.name,
isSelected: pricelist.id === this.currentOrder.pricelist.id,
item: pricelist,
}));
const { confirmed, payload: selectedPricelist } = await this.showPopup(
'SelectionPopup',
{
title: this.env._t('Select the pricelist'),
list: selectionList,
}
);
if (confirmed) {
this.currentOrder.set_pricelist(selectedPricelist);
}
}
}
SetPricelistButton.template = 'SetPricelistButton';
ProductScreen.addControlButton({
component: SetPricelistButton,
condition: function() {
return this.env.pos.config.use_pricelist && this.env.pos.pricelists.length > 1;
},
});
Registries.Component.add(SetPricelistButton);
return SetPricelistButton;
});
@@ -0,0 +1,13 @@
odoo.define('point_of_sale.HomeCategoryBreadcrumb', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class HomeCategoryBreadcrumb extends PosComponent {}
HomeCategoryBreadcrumb.template = 'HomeCategoryBreadcrumb';
Registries.Component.add(HomeCategoryBreadcrumb);
return HomeCategoryBreadcrumb;
});
@@ -0,0 +1,47 @@
odoo.define('point_of_sale.NumpadWidget', function(require) {
'use strict';
const { useState } = owl;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* IMPROVEMENT: Whenever new-orderline-selected is triggered,
* numpad mode should be set to 'quantity'.
*/
class NumpadWidget extends PosComponent {
constructor() {
super(...arguments);
this.state = useState({ mode: 'quantity' });
}
mounted() {
this.env.pos.on('change:cashier', () => {
if (!this.hasPriceControlRights && this.state.mode === 'price') {
this.state.mode = 'quantity';
}
});
}
willUnmount() {
this.env.pos.on('change:cashier', null, this);
}
get hasPriceControlRights() {
const cashier = this.env.pos.get('cashier') || this.env.pos.get_cashier();
return !this.env.pos.config.restrict_price_control || cashier.role == 'manager';
}
changeMode(mode) {
if (!this.hasPriceControlRights && mode === 'price') {
return;
}
this.state.mode = mode;
this.trigger('set-numpad-mode', { mode });
}
sendInput(key) {
this.trigger('numpad-click-input', { key });
}
}
NumpadWidget.template = 'NumpadWidget';
Registries.Component.add(NumpadWidget);
return NumpadWidget;
});
@@ -0,0 +1,13 @@
odoo.define('point_of_sale.OrderSummary', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class OrderSummary extends PosComponent {}
OrderSummary.template = 'OrderSummary';
Registries.Component.add(OrderSummary);
return OrderSummary;
});
@@ -0,0 +1,110 @@
odoo.define('point_of_sale.OrderWidget', function(require) {
'use strict';
const { useState, useRef, onPatched } = owl.hooks;
const { useListener } = require('web.custom_hooks');
const { onChangeOrder } = require('point_of_sale.custom_hooks');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class OrderWidget extends PosComponent {
constructor() {
super(...arguments);
useListener('select-line', this._selectLine);
useListener('edit-pack-lot-lines', this._editPackLotLines);
onChangeOrder(this._onPrevOrder, this._onNewOrder);
this.scrollableRef = useRef('scrollable');
this.scrollToBottom = false;
onPatched(() => {
// IMPROVEMENT
// This one just stays at the bottom of the orderlines list.
// Perhaps it is better to scroll to the added or modified orderline.
if (this.scrollToBottom) {
this.scrollableRef.el.scrollTop = this.scrollableRef.el.scrollHeight;
this.scrollToBottom = false;
}
});
this.state = useState({ total: 0, tax: 0 });
this._updateSummary();
}
get order() {
return this.env.pos.get_order();
}
get orderlinesArray() {
return this.order ? this.order.get_orderlines() : [];
}
_selectLine(event) {
this.order.select_orderline(event.detail.orderline);
}
// IMPROVEMENT: Might be better to lift this to ProductScreen
// because there is similar operation when clicking a product.
//
// Furthermore, what if a number different from 1 (or -1) is specified
// to an orderline that has product tracked by lot. Lot tracking (based
// on the current implementation) requires that 1 item per orderline is
// allowed.
async _editPackLotLines(event) {
const orderline = event.detail.orderline;
const isAllowOnlyOneLot = orderline.product.isAllowOnlyOneLot();
const packLotLinesToEdit = orderline.getPackLotLinesToEdit(isAllowOnlyOneLot);
const { confirmed, payload } = await this.showPopup('EditListPopup', {
title: this.env._t('Lot/Serial Number(s) Required'),
isSingleItem: isAllowOnlyOneLot,
array: packLotLinesToEdit,
});
if (confirmed) {
// Segregate the old and new packlot lines
const modifiedPackLotLines = Object.fromEntries(
payload.newArray.filter(item => item.id).map(item => [item.id, item.text])
);
const newPackLotLines = payload.newArray
.filter(item => !item.id)
.map(item => ({ lot_name: item.text }));
orderline.setPackLotLines({ modifiedPackLotLines, newPackLotLines });
}
this.order.select_orderline(event.detail.orderline);
}
_onNewOrder(order) {
if (order) {
order.orderlines.on(
'new-orderline-selected',
() => this.trigger('new-orderline-selected'),
this
);
order.orderlines.on('change', this._updateSummary, this);
order.orderlines.on(
'add remove',
() => {
this.scrollToBottom = true;
this._updateSummary();
},
this
);
order.on('change', this.render, this);
}
this._updateSummary();
this.trigger('new-orderline-selected');
}
_onPrevOrder(order) {
if (order) {
order.orderlines.off('new-orderline-selected', null, this);
order.orderlines.off('change', null, this);
order.orderlines.off('add remove', null, this);
order.off('change', null, this);
}
}
_updateSummary() {
const total = this.order ? this.order.get_total_with_tax() : 0;
const tax = this.order ? total - this.order.get_total_without_tax() : 0;
this.state.total = this.env.pos.format_currency(total);
this.state.tax = this.env.pos.format_currency(tax);
this.render();
}
}
OrderWidget.template = 'OrderWidget';
Registries.Component.add(OrderWidget);
return OrderWidget;
});
@@ -0,0 +1,25 @@
odoo.define('point_of_sale.Orderline', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class Orderline extends PosComponent {
selectLine() {
this.trigger('select-line', { orderline: this.props.line });
}
lotIconClicked() {
this.trigger('edit-pack-lot-lines', { orderline: this.props.line });
}
get addedClasses() {
return {
selected: this.props.line.selected,
};
}
}
Orderline.template = 'Orderline';
Registries.Component.add(Orderline);
return Orderline;
});
@@ -0,0 +1,49 @@
odoo.define('point_of_sale.ProductItem', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class ProductItem extends PosComponent {
/**
* For accessibility, pressing <space> should be like clicking the product.
* <enter> is not considered because it conflicts with the barcode.
*
* @param {KeyPressEvent} event
*/
spaceClickProduct(event) {
if (event.which === 32) {
this.trigger('click-product', this.props.product);
}
}
get imageUrl() {
const product = this.props.product;
return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
}
get pricelist() {
const current_order = this.env.pos.get_order();
if (current_order) {
return current_order.pricelist;
}
return this.env.pos.default_pricelist;
}
get price() {
const formattedUnitPrice = this.env.pos.format_currency(
this.props.product.get_price(this.pricelist, 1),
'Product Price'
);
if (this.props.product.to_weight) {
return `${formattedUnitPrice}/${
this.env.pos.units_by_id[this.props.product.uom_id[0]].name
}`;
} else {
return formattedUnitPrice;
}
}
}
ProductItem.template = 'ProductItem';
Registries.Component.add(ProductItem);
return ProductItem;
});
@@ -0,0 +1,13 @@
odoo.define('point_of_sale.ProductList', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class ProductList extends PosComponent {}
ProductList.template = 'ProductList';
Registries.Component.add(ProductList);
return ProductList;
});
@@ -0,0 +1,338 @@
odoo.define('point_of_sale.ProductScreen', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks');
class ProductScreen extends PosComponent {
constructor() {
super(...arguments);
useListener('update-selected-orderline', this._updateSelectedOrderline);
useListener('new-orderline-selected', this._newOrderlineSelected);
useListener('set-numpad-mode', this._setNumpadMode);
useListener('click-product', this._clickProduct);
useListener('click-customer', this._onClickCustomer);
useListener('click-pay', this._onClickPay);
useBarcodeReader({
product: this._barcodeProductAction,
weight: this._barcodeProductAction,
price: this._barcodeProductAction,
client: this._barcodeClientAction,
discount: this._barcodeDiscountAction,
error: this._barcodeErrorAction,
})
onChangeOrder(null, (newOrder) => newOrder && this.render());
NumberBuffer.use({
nonKeyboardInputEvent: 'numpad-click-input',
triggerAtInput: 'update-selected-orderline',
useWithBarcode: true,
});
this.numpadMode = 'quantity';
}
mounted() {
this.env.pos.on('change:selectedClient', this.render, this);
}
willUnmount() {
this.env.pos.off('change:selectedClient', null, this);
}
/**
* To be overridden by modules that checks availability of
* connected scale.
* @see _onScaleNotAvailable
*/
get isScaleAvailable() {
return true;
}
get client() {
return this.env.pos.get_client();
}
get currentOrder() {
return this.env.pos.get_order();
}
get controlButtons() {
return ProductScreen.controlButtons
.filter((cb) => {
return cb.condition.bind(this)();
})
.map((cb) =>
Object.assign({}, cb, { component: Registries.Component.get(cb.component) })
);
}
async _clickProduct(event) {
if (!this.currentOrder) {
this.env.pos.add_new_order();
}
const product = event.detail;
let draftPackLotLines, weight, packLotLinesToEdit;
// Gather lot information if required.
if (['serial', 'lot'].includes(product.tracking)) {
const isAllowOnlyOneLot = product.isAllowOnlyOneLot();
if (isAllowOnlyOneLot) {
packLotLinesToEdit = [];
} else {
const orderline = this.currentOrder
.get_orderlines()
.filter(line => !line.get_discount())
.find(line => line.product.id === product.id);
if (orderline) {
packLotLinesToEdit = orderline.getPackLotLinesToEdit();
} else {
packLotLinesToEdit = [];
}
}
const { confirmed, payload } = await this.showPopup('EditListPopup', {
title: this.env._t('Lot/Serial Number(s) Required'),
isSingleItem: isAllowOnlyOneLot,
array: packLotLinesToEdit,
});
if (confirmed) {
// Segregate the old and new packlot lines
const modifiedPackLotLines = Object.fromEntries(
payload.newArray.filter(item => item.id).map(item => [item.id, item.text])
);
const newPackLotLines = payload.newArray
.filter(item => !item.id)
.map(item => ({ lot_name: item.text }));
draftPackLotLines = { modifiedPackLotLines, newPackLotLines };
} else {
// We don't proceed on adding product.
return;
}
}
// Take the weight if necessary.
if (product.to_weight && this.env.pos.config.iface_electronic_scale) {
// Show the ScaleScreen to weigh the product.
if (this.isScaleAvailable) {
const { confirmed, payload } = await this.showTempScreen('ScaleScreen', {
product,
});
if (confirmed) {
weight = payload.weight;
} else {
// do not add the product;
return;
}
} else {
await this._onScaleNotAvailable();
}
}
// Add the product after having the extra information.
this.currentOrder.add_product(product, {
draftPackLotLines,
quantity: weight,
});
NumberBuffer.reset();
}
async _setNumpadMode(event) {
const { mode } = event.detail;
this.numpadMode = mode;
NumberBuffer.reset();
}
disallowLineQuantityChange() {
//This function will check if we should check or not the way we handle the line deletion.
//If the localisation prevent the line deletion, this function will return true, following the override in the multiple modules.
//By default, we'll always set the quantity according to the buffer input.
return false;
}
async _updateSelectedOrderline(event) {
if(this.disallowLineQuantityChange()) {
let order = this.env.pos.get_order();
let selectedLine = order.get_selected_orderline();
let last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length-1];
let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity();
if(last_id != selectedLine.cid)
this._showDecreaseQuantityPopup();
else if(currentQuantity === 1 && event.detail.buffer < 1) // Allow to put a value below 1 for the last product entry.
this._setValue(event.detail.buffer);
else if(currentQuantity < event.detail.buffer)
this._setValue(event.detail.buffer);
else if(event.detail.buffer < currentQuantity)
this._showDecreaseQuantityPopup();
} else {
let { buffer } = event.detail;
let val = buffer === null ? 'remove' : buffer;
this._setValue(val);
}
}
async _newOrderlineSelected() {
NumberBuffer.reset();
}
_setValue(val) {
if (this.currentOrder.get_selected_orderline()) {
if (this.numpadMode === 'quantity') {
this.currentOrder.get_selected_orderline().set_quantity(val);
} else if (this.numpadMode === 'discount') {
this.currentOrder.get_selected_orderline().set_discount(val);
} else if (this.numpadMode === 'price') {
var selected_orderline = this.currentOrder.get_selected_orderline();
selected_orderline.price_manually_set = true;
selected_orderline.set_unit_price(val);
}
if (this.env.pos.config.iface_customer_facing_display) {
this.env.pos.send_current_order_to_customer_facing_display();
}
}
}
_barcodeProductAction(code) {
// NOTE: scan_product call has side effect in pos if it returned true.
if (!this.env.pos.scan_product(code)) {
this._barcodeErrorAction(code);
}
}
_barcodeClientAction(code) {
const partner = this.env.pos.db.get_partner_by_barcode(code.code);
if (partner) {
if (this.currentOrder.get_client() !== partner) {
this.currentOrder.set_client(partner);
this.currentOrder.set_pricelist(
_.findWhere(this.env.pos.pricelists, {
id: partner.property_product_pricelist[0],
}) || this.env.pos.default_pricelist
);
}
return true;
}
this._barcodeErrorAction(code);
return false;
}
_barcodeDiscountAction(code) {
var last_orderline = this.currentOrder.get_last_orderline();
if (last_orderline) {
last_orderline.set_discount(code.value);
}
}
// IMPROVEMENT: The following two methods should be in PosScreenComponent?
// Why? Because once we start declaring barcode actions in different
// screens, these methods will also be declared over and over.
_barcodeErrorAction(code) {
this.showPopup('ErrorBarcodePopup', { code: this._codeRepr(code) });
}
_codeRepr(code) {
if (code.code.length > 32) {
return code.code.substring(0, 29) + '...';
} else {
return code.code;
}
}
/**
* override this method to perform procedure if the scale is not available.
* @see isScaleAvailable
*/
async _onScaleNotAvailable() {}
async _showDecreaseQuantityPopup() {
const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', {
startingValue: 0,
title: this.env._t('Decrease the quantity by'),
});
let decreaseQuantity = Math.abs(inputNumber);
if (confirmed && decreaseQuantity) {
let order = this.env.pos.get_order();
let selectedLine = this.env.pos.get_order().get_selected_orderline();
let quantity = 0;
order.get_orderlines().forEach(orderLine => {
if(orderLine.get_product().id === selectedLine.get_product().id && orderLine.get_discount() === selectedLine.get_discount())
quantity += orderLine.get_quantity();
});
if(decreaseQuantity > quantity) {
await this.showPopup('ErrorPopup', {
title: this.env._t('Order error'),
body: this.env._t('Not allowed to take back more than was ordered.'),
});
} else {
let decreaseLine = selectedLine.clone();
decreaseLine.order = order;
decreaseLine.set_quantity(-decreaseQuantity);
order.add_orderline(decreaseLine);
}
}
}
async _onClickCustomer() {
// IMPROVEMENT: This code snippet is very similar to selectClient of PaymentScreen.
const currentClient = this.currentOrder.get_client();
const { confirmed, payload: newClient } = await this.showTempScreen(
'ClientListScreen',
{ client: currentClient }
);
if (confirmed) {
this.currentOrder.set_client(newClient);
this.currentOrder.updatePricelist(newClient);
}
}
_onClickPay() {
this.showScreen('PaymentScreen');
}
}
ProductScreen.template = 'ProductScreen';
ProductScreen.controlButtons = [];
/**
* @param {Object} controlButton
* @param {Function} controlButton.component
* Can be any base class or base class callback that is added in the Registries.Component.
* @param {Function} controlButton.condition zero argument function that is bound
* to the instance of ProductScreen, such that `this.env.pos` can be used
* inside the function.
* @param {Array} [controlButton.position] array of two elements
* [locator, relativeTo]
* locator: string -> any of ('before', 'after', 'replace')
* relativeTo: string -> other controlButtons component name
*/
ProductScreen.addControlButton = function(controlButton) {
// We set the name first.
if (!controlButton.name) {
controlButton.name = controlButton.component.name;
}
// If no position is set, we just push it to the array.
if (!controlButton.position) {
this.controlButtons.push(controlButton);
} else {
// Find where to put the new controlButton.
const [locator, relativeTo] = controlButton.position;
let whereIndex = -1;
for (let i = 0; i < this.controlButtons.length; i++) {
if (this.controlButtons[i].name === relativeTo) {
if (['before', 'replace'].includes(locator)) {
whereIndex = i;
} else if (locator === 'after') {
whereIndex = i + 1;
}
break;
}
}
// If found where to put, then perform the necessary mutation of
// the buttons array.
// Else, we just push this controlButton to the array.
if (whereIndex > -1) {
this.controlButtons.splice(
whereIndex,
locator === 'replace' ? 1 : 0,
controlButton
);
} else {
let warningMessage =
`'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` +
'It is pushed to the controlButtons stack instead.';
console.warn(warningMessage);
this.controlButtons.push(controlButton);
}
}
};
Registries.Component.add(ProductScreen);
return ProductScreen;
});
@@ -0,0 +1,72 @@
odoo.define('point_of_sale.ProductsWidget', function(require) {
'use strict';
const { useState } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
class ProductsWidget extends PosComponent {
/**
* @param {Object} props
* @param {number?} props.startCategoryId
*/
constructor() {
super(...arguments);
useListener('switch-category', this._switchCategory);
useListener('update-search', this._updateSearch);
useListener('clear-search', this._clearSearch);
this.state = useState({ searchWord: '' });
}
mounted() {
this.env.pos.on('change:selectedCategoryId', this.render, this);
}
willUnmount() {
this.env.pos.off('change:selectedCategoryId', null, this);
}
get selectedCategoryId() {
return this.env.pos.get('selectedCategoryId');
}
get searchWord() {
return this.state.searchWord.trim();
}
get productsToDisplay() {
if (this.searchWord !== '') {
return this.env.pos.db.search_product_in_category(
this.selectedCategoryId,
this.searchWord
);
} else {
return this.env.pos.db.get_product_by_category(this.selectedCategoryId);
}
}
get subcategories() {
return this.env.pos.db
.get_category_childs_ids(this.selectedCategoryId)
.map(id => this.env.pos.db.get_category_by_id(id));
}
get breadcrumbs() {
if (this.selectedCategoryId === this.env.pos.db.root_category_id) return [];
return [
...this.env.pos.db
.get_category_ancestors_ids(this.selectedCategoryId)
.slice(1),
this.selectedCategoryId,
].map(id => this.env.pos.db.get_category_by_id(id));
}
_switchCategory(event) {
this.env.pos.set('selectedCategoryId', event.detail);
}
_updateSearch(event) {
this.state.searchWord = event.detail;
}
_clearSearch() {
this.state.searchWord = '';
}
}
ProductsWidget.template = 'ProductsWidget';
Registries.Component.add(ProductsWidget);
return ProductsWidget;
});
@@ -0,0 +1,29 @@
odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) {
'use strict';
const { useRef } = owl.hooks;
const { debounce } = owl.utils;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class ProductsWidgetControlPanel extends PosComponent {
constructor() {
super(...arguments);
this.searchTimeout = null;
this.searchWordInput = useRef('search-word-input');
this.updateSearch = debounce(this.updateSearch, 100);
}
clearSearch() {
this.searchWordInput.el.value = '';
this.trigger('clear-search');
}
updateSearch(event) {
this.trigger('update-search', event.target.value);
}
}
ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel';
Registries.Component.add(ProductsWidgetControlPanel);
return ProductsWidgetControlPanel;
});
@@ -0,0 +1,47 @@
odoo.define('point_of_sale.OrderReceipt', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class OrderReceipt extends PosComponent {
constructor() {
super(...arguments);
this._receiptEnv = this.props.order.getOrderReceiptEnv();
}
willUpdateProps(nextProps) {
this._receiptEnv = nextProps.order.getOrderReceiptEnv();
}
get receipt() {
return this.receiptEnv.receipt;
}
get orderlines() {
return this.receiptEnv.orderlines;
}
get paymentlines() {
return this.receiptEnv.paymentlines;
}
get isTaxIncluded() {
return Math.abs(this.receipt.subtotal - this.receipt.total_with_tax) <= 0.000001;
}
get receiptEnv () {
return this._receiptEnv;
}
isSimple(line) {
return (
line.discount === 0 &&
line.unit_name === 'Units' &&
line.quantity === 1 &&
!(
line.display_discount_policy == 'without_discount' &&
line.price != line.price_lst
)
);
}
}
OrderReceipt.template = 'OrderReceipt';
Registries.Component.add(OrderReceipt);
return OrderReceipt;
});
@@ -0,0 +1,132 @@
odoo.define('point_of_sale.ReceiptScreen', function(require) {
'use strict';
const core = require('web.core');
const { useRef, useState } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks');
const Registries = require('point_of_sale.Registries');
const _t = core._t;
class ReceiptScreen extends PosComponent {
/**
* Optional props:
* printInvoiceIsShown: Boolean
*/
constructor() {
super(...arguments);
useErrorHandlers();
onChangeOrder(null, (newOrder) => newOrder && this.render());
this.state = useState({ printInvoiceIsShown: this.props.printInvoiceIsShown });
this.orderReceipt = useRef('order-receipt');
}
mounted() {
// Here, we send a task to the event loop that handles
// the printing of the receipt when the component is mounted.
// We are doing this because we want the receipt screen to be
// displayed regardless of what happen to the handleAutoPrint
// call.
setTimeout(async () => await this.handleAutoPrint(), 0);
}
get change() {
return this.env.pos.format_currency(this.currentOrder.get_change());
}
get currentOrder() {
return this.env.pos.get_order();
}
get nextScreen() {
return { name: 'ProductScreen' };
}
/**
* This function is called outside the rendering call stack. This way,
* we don't block the displaying of ReceiptScreen when it is mounted; additionally,
* any error that can happen during the printing does not affect the rendering.
*/
async handleAutoPrint() {
if (this._shouldAutoPrint() && !this.currentOrder.is_to_email()) {
await this.printReceipt();
if (this.currentOrder._printed && this._shouldCloseImmediately()) {
this.orderDone();
}
}
}
orderDone() {
this.currentOrder.finalize();
const { name, props } = this.nextScreen;
this.showScreen(name, props);
}
async printReceipt() {
if (this.env.pos.proxy.printer) {
const printResult = await this.env.pos.proxy.printer.print_receipt(
this.orderReceipt.el.outerHTML
);
if (printResult.successful) {
this.currentOrder._printed = true;
} else {
await this.showPopup('ErrorPopup', {
title: printResult.message.title,
body: printResult.message.body,
});
}
} else {
await this._printWeb();
}
}
async onPrintInvoice() {
// The button element of this event handler appears when the order
// failed to sync and is to be invoiced. What we do here is to try
// to sync again to eventually print the invoice.
try {
await this.env.pos.push_and_invoice_order(this.currentOrder);
this.state.printInvoiceIsShown = false;
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
await this._handlePushOrderError(error);
}
}
}
_shouldAutoPrint() {
return this.env.pos.config.iface_print_auto && !this.currentOrder._printed;
}
_shouldCloseImmediately() {
var invoiced_finalized = this.currentOrder.is_to_invoice()
? this.currentOrder.finalized
: true;
return (
this.env.pos.proxy.printer &&
this.env.pos.config.iface_print_skip_screen &&
invoiced_finalized
);
}
async _printWeb() {
if ($.browser.safari) {
document.execCommand('print', false, null);
} else {
try {
window.print();
this.currentOrder._printed = true;
} catch (err) {
if (navigator.userAgent.toLowerCase().indexOf('android') > -1) {
await this.showPopup('ErrorPopup', {
title: _t('Printing is not supported on some android browsers'),
body: _t(
'Printing is not supported on some android browsers due to no default printing protocol ' +
'is available. It is possible to print your tickets by making use of an IoT Box.'
),
});
} else {
throw err;
}
}
}
}
}
ReceiptScreen.template = 'ReceiptScreen';
Registries.Component.add(ReceiptScreen);
return ReceiptScreen;
});
@@ -0,0 +1,18 @@
odoo.define('point_of_sale.WrappedProductNameLines', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class WrappedProductNameLines extends PosComponent {
constructor() {
super(...arguments);
this.line = this.props.line;
}
}
WrappedProductNameLines.template = 'WrappedProductNameLines';
Registries.Component.add(WrappedProductNameLines);
return WrappedProductNameLines;
});
@@ -0,0 +1,102 @@
odoo.define('point_of_sale.ScaleScreen', function(require) {
'use strict';
const { useState, useExternalListener } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
const { round_precision: round_pr } = require('web.utils');
const Registries = require('point_of_sale.Registries');
class ScaleScreen extends PosComponent {
/**
* @param {Object} props
* @param {Object} props.product The product to weight.
*/
constructor() {
super(...arguments);
useExternalListener(document, 'keyup', this._onHotkeys);
this.state = useState({ weight: 0 });
}
mounted() {
// start the scale reading
this._readScale();
}
willUnmount() {
// stop the scale reading
this.env.pos.proxy_queue.clear();
}
back() {
this.props.resolve({ confirmed: false, payload: null });
this.trigger('close-temp-screen');
}
confirm() {
this.props.resolve({
confirmed: true,
payload: { weight: this.state.weight },
});
this.trigger('close-temp-screen');
}
_onHotkeys(event) {
if (event.key === 'Escape') {
this.back();
} else if (event.key === 'Enter') {
this.confirm();
}
}
_readScale() {
this.env.pos.proxy_queue.schedule(this._setWeight.bind(this), {
duration: 500,
repeat: true,
});
}
async _setWeight() {
const reading = await this.env.pos.proxy.scale_read();
this.state.weight = reading.weight;
}
get _activePricelist() {
const current_order = this.env.pos.get_order();
let current_pricelist = this.env.pos.default_pricelist;
if (current_order) {
current_pricelist = current_order.pricelist;
}
return current_pricelist;
}
get productWeightString() {
const defaultstr = (this.state.weight || 0).toFixed(3) + ' Kg';
if (!this.props.product || !this.env.pos) {
return defaultstr;
}
const unit_id = this.props.product.uom_id;
if (!unit_id) {
return defaultstr;
}
const unit = this.env.pos.units_by_id[unit_id[0]];
const weight = round_pr(this.state.weight || 0, unit.rounding);
let weightstr = weight.toFixed(Math.ceil(Math.log(1.0 / unit.rounding) / Math.log(10)));
weightstr += ' ' + unit.name;
return weightstr;
}
get computedPriceString() {
return this.env.pos.format_currency(this.productPrice * this.state.weight);
}
get productPrice() {
const product = this.props.product;
return (product ? product.get_price(this._activePricelist, this.state.weight) : 0) || 0;
}
get productName() {
return (
(this.props.product ? this.props.product.display_name : undefined) ||
'Unnamed Product'
);
}
get productUom() {
return this.props.product
? this.env.pos.units_by_id[this.props.product.uom_id[0]].name
: '';
}
}
ScaleScreen.template = 'ScaleScreen';
Registries.Component.add(ScaleScreen);
return ScaleScreen;
});
@@ -16,7 +16,8 @@ var BarcodeReader = core.Class.extend({
init: function (attributes) {
this.pos = attributes.pos;
this.action_callback = {};
this.action_callbacks = {};
this.exclusive_callbacks = {};
this.proxy = attributes.proxy;
this.remote_scanning = false;
this.remote_active = 0;
@@ -34,21 +35,6 @@ var BarcodeReader = core.Class.extend({
this.barcode_parser = barcode_parser;
},
save_callbacks: function () {
var callbacks = {};
for (var name in this.action_callback) {
callbacks[name] = this.action_callback[name];
}
this.action_callback_stack.push(callbacks);
},
restore_callbacks: function () {
if (this.action_callback_stack.length) {
var callbacks = this.action_callback_stack.pop();
this.action_callback = callbacks;
}
},
// when a barcode is scanned and parsed, the callback corresponding
// to its type is called with the parsed_barcode as a parameter.
// (parsed_barcode is the result of parse_barcode(barcode))
@@ -59,36 +45,75 @@ var BarcodeReader = core.Class.extend({
//
// possible actions include :
// 'product' | 'cashier' | 'client' | 'discount'
set_action_callback: function (action, callback) {
if (arguments.length == 2) {
this.action_callback[action] = callback;
set_action_callback: function (name, callback) {
if (this.action_callbacks[name]) {
this.action_callbacks[name].add(callback);
} else {
var actions = arguments[0];
for (var action in actions) {
this.set_action_callback(action,actions[action]);
this.action_callbacks[name] = new Set([callback]);
}
},
remove_action_callback: function(name, callback) {
if (!callback) {
delete this.action_callbacks[name];
return;
}
const callbacks = this.action_callbacks[name];
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
delete this.action_callbacks[name];
}
}
},
//remove all action callbacks
reset_action_callbacks: function () {
for (var action in this.action_callback) {
this.action_callback[action] = undefined;
/**
* Allow setting of exclusive callbacks. If there are exclusive callbacks,
* these callbacks are called neglecting the regular callbacks. This is
* useful for rendered Components that wants to take exclusive access
* to the barcode reader.
*
* @param {String} name
* @param {Function} callback function that takes parsed barcode
*/
set_exclusive_callback: function (name, callback) {
if (this.exclusive_callbacks[name]) {
this.exclusive_callbacks[name].add(callback);
} else {
this.exclusive_callbacks[name] = new Set([callback]);
}
},
remove_exclusive_callback: function (name, callback) {
if (!callback) {
delete this.exclusive_callbacks[name];
return;
}
const callbacks = this.exclusive_callbacks[name];
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
delete this.exclusive_callbacks[name];
}
}
},
scan: function (code) {
if (!code) {
return;
}
var parsed_result = this.barcode_parser.parse_barcode(code);
if (this.action_callback[parsed_result.type]) {
this.action_callback[parsed_result.type](parsed_result);
} else if (this.action_callback.error) {
this.action_callback.error(parsed_result);
if (!code) return;
const callbacks = Object.keys(this.exclusive_callbacks).length
? this.exclusive_callbacks
: this.action_callbacks;
const parsed_result = this.barcode_parser.parse_barcode(code);
if (callbacks[parsed_result.type]) {
[...callbacks[parsed_result.type]].map((cb) => cb(parsed_result));
} else if (callbacks.error) {
[...callbacks.error].map((cb) => cb(parsed_result));
} else {
console.warn("Ignored Barcode Scan:", parsed_result);
console.warn('Ignored Barcode Scan:', parsed_result);
}
},
// the barcode scanner will listen on the hw_proxy/scanner interface for
@@ -1,937 +0,0 @@
odoo.define('point_of_sale.chrome', function (require) {
"use strict";
var PosBaseWidget = require('point_of_sale.BaseWidget');
var gui = require('point_of_sale.gui');
var keyboard = require('point_of_sale.keyboard');
var models = require('point_of_sale.models');
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var ajax = require('web.ajax');
var CrashManager = require('web.CrashManager').CrashManager;
var rpc = require('web.rpc');
var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents;
var _t = core._t;
var _lt = core._lt;
var QWeb = core.qweb;
/* -------- The Order Selector -------- */
// Allows the cashier to create / delete and
// switch between orders.
var OrderSelectorWidget = PosBaseWidget.extend({
template: 'OrderSelectorWidget',
init: function(parent, options) {
this._super(parent, options);
this.pos.get('orders').bind('add remove change',this.renderElement,this);
this.pos.bind('change:selectedOrder',this.renderElement,this);
},
get_order_by_uid: function(uid) {
var orders = this.pos.get_order_list();
for (var i = 0; i < orders.length; i++) {
if (orders[i].uid === uid) {
return orders[i];
}
}
return undefined;
},
order_click_handler: function(event,$el) {
var order = this.get_order_by_uid($el.data('uid'));
if (order) {
this.pos.set_order(order);
}
},
neworder_click_handler: function(event, $el) {
this.pos.add_new_order();
},
deleteorder_click_handler: function(event, $el) {
var self = this;
var order = this.pos.get_order();
if (!order) {
return;
} else if ( !order.is_empty() ){
this.gui.show_popup('confirm',{
'title': _t('Destroy Current Order ?'),
'body': _t('You will lose any data associated with the current order'),
confirm: function(){
self.pos.delete_current_order();
},
});
} else {
this.pos.delete_current_order();
}
},
renderElement: function(){
var self = this;
this._super();
this.$('.order-button.select-order').click(function(event){
self.order_click_handler(event,$(this));
});
this.$('.neworder-button').click(function(event){
self.neworder_click_handler(event,$(this));
});
this.$('.deleteorder-button').click(function(event){
self.deleteorder_click_handler(event,$(this));
});
},
});
/* ------- The User Name Widget ------- */
// Displays the current cashier's name
var UsernameWidget = PosBaseWidget.extend({
template: 'UsernameWidget',
init: function(parent, options){
options = options || {};
this._super(parent,options);
},
get_name: function(){
var user = this.pos.get_cashier();
if(user){
return user.name;
}else{
return "";
}
},
});
/* -------- The Header Button --------- */
// Used to quickly add buttons with simple
// labels and actions to the point of sale
// header.
var HeaderButtonWidget = PosBaseWidget.extend({
template: 'HeaderButtonWidget',
init: function(parent, options){
options = options || {};
this._super(parent, options);
this.action = options.action;
this.label = options.label;
this.button_class = options.button_class;
},
renderElement: function(){
var self = this;
this._super();
if(this.action){
this.$el.click(function(){
self.action();
});
}
},
show: function() { this.$el.removeClass('oe_hidden'); },
hide: function() { this.$el.addClass('oe_hidden'); },
});
/* --------- The Debug Widget --------- */
// The debug widget lets the user control
// and monitor the hardware and software status
// without the use of the proxy, or to access
// the raw locally stored db values, useful
// for debugging
var DebugWidget = PosBaseWidget.extend({
template: "DebugWidget",
eans:{
admin_badge: '0410100000006',
client_badge: '0420200000004',
invalid_ean: '1232456',
soda_33cl: '5449000000996',
oranges_kg: '2100002031410',
lemon_price: '2301000001560',
unknown_product: '9900000000004',
},
events:[
'open_cashbox',
'print_receipt',
'scale_read',
],
init: function(parent,options){
this._super(parent,options);
var self = this;
// for dragging the debug widget around
this.dragging = false;
this.dragpos = {x:0, y:0};
function eventpos(event){
if(event.touches && event.touches[0]){
return {x: event.touches[0].screenX, y: event.touches[0].screenY};
}else{
return {x: event.screenX, y: event.screenY};
}
}
this.dragend_handler = function(event){
self.dragging = false;
};
this.dragstart_handler = function(event){
self.dragging = true;
self.dragpos = eventpos(event);
};
this.dragmove_handler = function(event){
if(self.dragging){
var top = this.offsetTop;
var left = this.offsetLeft;
var pos = eventpos(event);
var dx = pos.x - self.dragpos.x;
var dy = pos.y - self.dragpos.y;
self.dragpos = pos;
this.style.right = 'auto';
this.style.bottom = 'auto';
this.style.left = left + dx + 'px';
this.style.top = top + dy + 'px';
}
event.preventDefault();
event.stopPropagation();
};
},
show: function() {
this.$el.css({opacity:0});
this.$el.removeClass('oe_hidden');
this.$el.animate({opacity:1},250,'swing');
},
hide: function() {
var self = this;
this.$el.animate({opacity:0,},250,'swing',function(){
self.$el.addClass('oe_hidden');
});
},
start: function(){
var self = this;
if (this.pos.debug) {
this.show();
}
this.el.addEventListener('mouseleave', this.dragend_handler);
this.el.addEventListener('mouseup', this.dragend_handler);
this.el.addEventListener('touchend', this.dragend_handler);
this.el.addEventListener('touchcancel',this.dragend_handler);
this.el.addEventListener('mousedown', this.dragstart_handler);
this.el.addEventListener('touchstart', this.dragstart_handler);
this.el.addEventListener('mousemove', this.dragmove_handler);
this.el.addEventListener('touchmove', this.dragmove_handler);
this.$('.toggle').click(function(){
self.hide();
});
this.$('.button.set_weight').click(function(){
var kg = Number(self.$('input.weight').val());
if(!isNaN(kg)){
self.pos.proxy.debug_set_weight(kg);
}
});
this.$('.button.reset_weight').click(function(){
self.$('input.weight').val('');
self.pos.proxy.debug_reset_weight();
});
this.$('.button.custom_ean').click(function(){
var ean = self.pos.barcode_reader.barcode_parser.sanitize_ean(self.$('input.ean').val() || '0');
self.$('input.ean').val(ean);
self.pos.barcode_reader.scan(ean);
});
this.$('.button.barcode').click(function(){
self.pos.barcode_reader.scan(self.$('input.ean').val());
});
this.$('.button.delete_orders').click(function(){
self.gui.show_popup('confirm',{
'title': _t('Delete Paid Orders ?'),
'body': _t('This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.'),
confirm: function(){
self.pos.db.remove_all_orders();
self.pos.set_synch('connected', 0);
},
});
});
this.$('.button.delete_unpaid_orders').click(function(){
self.gui.show_popup('confirm',{
'title': _t('Delete Unpaid Orders ?'),
'body': _t('This operation will destroy all unpaid orders in the browser. You will lose all the unsaved data and exit the point of sale. This operation cannot be undone.'),
confirm: function(){
self.pos.db.remove_all_unpaid_orders();
window.location = '/';
},
});
});
this.$('.button.export_unpaid_orders').click(function(){
self.gui.prepare_download_link(
self.pos.export_unpaid_orders(),
_t("unpaid orders") + ' ' + moment().format('YYYY-MM-DD-HH-mm-ss') + '.json',
".export_unpaid_orders", ".download_unpaid_orders"
);
});
this.$('.button.export_paid_orders').click(function() {
self.gui.prepare_download_link(
self.pos.export_paid_orders(),
_t("paid orders") + ' ' + moment().format('YYYY-MM-DD-HH-mm-ss') + '.json',
".export_paid_orders", ".download_paid_orders"
);
});
this.$('.button.display_refresh').click(function () {
self.pos.proxy.message('display_refresh', {});
});
this.$('.button.import_orders input').on('change', function(event) {
var file = event.target.files[0];
if (file) {
var reader = new FileReader();
reader.onload = function(event) {
var report = self.pos.import_orders(event.target.result);
self.gui.show_popup('orderimport',{report:report});
};
reader.readAsText(file);
}
});
_.each(this.events, function(name){
self.pos.proxy.add_notification(name,function(){
self.$('.event.'+name).stop().clearQueue().css({'background-color':'#6CD11D'});
self.$('.event.'+name).animate({'background-color':'#1E1E1E'},2000);
});
});
},
});
/* --------- The Status Widget -------- */
// Base class for widgets that want to display
// status in the point of sale header.
var StatusWidget = PosBaseWidget.extend({
status: ['connected','connecting','disconnected','warning','error'],
set_status: function(status,msg){
for(var i = 0; i < this.status.length; i++){
this.$('.js_'+this.status[i]).addClass('oe_hidden');
}
this.$('.js_'+status).removeClass('oe_hidden');
if(msg){
this.$('.js_msg').removeClass('oe_hidden').html(msg);
}else{
this.$('.js_msg').addClass('oe_hidden').html('');
}
},
});
/* ------- Synch. Notifications ------- */
// Displays if there are orders that could
// not be submitted, and how many.
var SynchNotificationWidget = StatusWidget.extend({
template: 'SynchNotificationWidget',
start: function(){
var self = this;
this.pos.bind('change:synch', function(pos,synch){
self.set_status(synch.state, synch.pending);
});
this.$el.click(function(){
self.pos.push_order(null,{'show_error':true});
});
},
});
/* --------- The Proxy Status --------- */
// Displays the status of the hardware proxy
// (connected, disconnected, errors ... )
var ProxyStatusWidget = StatusWidget.extend({
template: 'ProxyStatusWidget',
set_smart_status: function(status){
if(status.status === 'connected'){
var warning = false;
var msg = '';
if(this.pos.config.iface_scan_via_proxy){
var scanner = status.drivers.scanner ? status.drivers.scanner.status : false;
if( scanner != 'connected' && scanner != 'connecting'){
warning = true;
msg += _t('Scanner');
}
}
if( this.pos.config.iface_print_via_proxy ||
this.pos.config.iface_cashdrawer ){
var printer = status.drivers.printer ? status.drivers.printer.status : false;
if (printer != 'connected' && printer != 'connecting') {
warning = true;
msg = msg ? msg + ' & ' : msg;
msg += _t('Printer');
}
}
if( this.pos.config.iface_electronic_scale ){
var scale = status.drivers.scale ? status.drivers.scale.status : false;
if( scale != 'connected' && scale != 'connecting' ){
warning = true;
msg = msg ? msg + ' & ' : msg;
msg += _t('Scale');
}
}
msg = msg ? msg + ' ' + _t('Offline') : msg;
this.set_status(warning ? 'warning' : 'connected', msg);
}else{
this.set_status(status.status, status.msg || '');
}
},
start: function(){
var self = this;
this.set_smart_status(this.pos.proxy.get('status'));
this.pos.proxy.on('change:status',this,function(eh,status){ //FIXME remove duplicate changes
self.set_smart_status(status.newValue);
});
this.$el.click(function(){
self.pos.connect_to_proxy();
});
},
});
/* --------- The Sale Details --------- */
/** Print an overview of todays sales.
*
* If the current cashier is a manager all sales of the day will be printed, else only the sales of the current
* session will be printed.
*/
var SaleDetailsButton = PosBaseWidget.extend({
template: 'SaleDetailsButton',
start: function(){
var self = this;
this.$el.click(function(){
self.print_sale_details();
});
},
/** Print an overview of todays sales.
*
* By default this will print all sales of the day for current PoS config.
*/
print_sale_details: function () {
var self = this;
rpc.query({
model: 'report.point_of_sale.report_saledetails',
method: 'get_sale_details',
args: [false, false, false, [this.pos.pos_session.id]],
})
.then(function(result){
var env = {
widget: new PosBaseWidget(self),
company: self.pos.company,
pos: self.pos,
products: result.products,
payments: result.payments,
taxes: result.taxes,
total_paid: result.total_paid,
date: (new Date()).toLocaleString(),
};
var report = QWeb.render('SaleDetailsReport', env);
self.pos.proxy.printer.print_receipt(report);
});
},
});
/* User interface for distant control over the Client display on the IoT Box */
// The boolean posbox_supports_display (in devices.js) will allow interaction to the IoT Box on true, prevents it otherwise
// We don't want the incompatible IoT Box to be flooded with 404 errors on arrival of our many requests as it triggers losses of connections altogether
var ClientScreenWidget = PosBaseWidget.extend({
template: 'ClientScreenWidget',
change_status_display: function(status) {
var msg = ''
if (status === 'success') {
this.$('.js_warning').addClass('oe_hidden');
this.$('.js_disconnected').addClass('oe_hidden');
this.$('.js_connected').removeClass('oe_hidden');
} else if (status === 'warning') {
this.$('.js_disconnected').addClass('oe_hidden');
this.$('.js_connected').addClass('oe_hidden');
this.$('.js_warning').removeClass('oe_hidden');
msg = _t('Connected, Not Owned');
} else {
this.$('.js_warning').addClass('oe_hidden');
this.$('.js_connected').addClass('oe_hidden');
this.$('.js_disconnected').removeClass('oe_hidden');
msg = _t('Disconnected')
if (status === 'not_found') {
msg = _t('Client Screen Unsupported. Please upgrade the IoT Box')
}
}
this.$('.oe_customer_display_text').text(msg);
},
status_loop: function() {
var self = this;
function loop() {
if (self.pos.proxy.posbox_supports_display) {
self.pos.proxy.test_ownership_of_client_screen().then(
function (data) {
if (typeof data === 'string') {
data = JSON.parse(data);
}
if (data.status === 'OWNER') {
self.change_status_display('success');
} else {
self.change_status_display('warning');
}
setTimeout(loop, 3000);
},
function (err) {
if (err.abort) {
// Stop the loop
return;
}
if (typeof err == "undefined") {
self.change_status_display('failure');
} else {
self.change_status_display('not_found');
self.pos.proxy.posbox_supports_display = false;
}
setTimeout(loop, 3000);
}
);
}
}
loop();
},
start: function(){
if (this.pos.config.iface_customer_facing_display) {
this.show();
var self = this;
this.$el.click(function(){
self.pos.render_html_for_customer_facing_display().then(function(rendered_html) {
self.pos.proxy.take_ownership_over_client_screen(rendered_html).then(
function(data) {
if (typeof data === 'string') {
data = JSON.parse(data);
}
if (data.status === 'success') {
self.change_status_display('success');
} else {
self.change_status_display('warning');
}
if (!self.pos.proxy.posbox_supports_display) {
self.pos.proxy.posbox_supports_display = true;
self.status_loop();
}
},
function(err) {
if (typeof err == "undefined") {
self.change_status_display('failure');
} else {
self.change_status_display('not_found');
}
});
});
});
this.status_loop();
} else {
this.hide();
}
},
});
/*--------------------------------------*\
| THE CHROME |
\*======================================*/
// The Chrome is the main widget that contains
// all other widgets in the PointOfSale.
//
// It is the first object instanciated and the
// starting point of the point of sale code.
//
// It is mainly composed of :
// - a header, containing the list of orders
// - a leftpane, containing the list of bought
// products (orderlines)
// - a rightpane, containing the screens
// (see pos_screens.js)
// - popups
// - an onscreen keyboard
// - .gui which controls the switching between
// screens and the showing/closing of popups
var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
template: 'Chrome',
init: function() {
var self = this;
this._super(arguments[0],{});
this.started = new $.Deferred(); // resolves when DOM is online
this.ready = new $.Deferred(); // resolves when the whole GUI has been loaded
this.pos = new models.PosModel(this.getSession(), {chrome:this});
this.gui = new gui.Gui({pos: this.pos, chrome: this});
this.chrome = this; // So that chrome's childs have chrome set automatically
this.pos.gui = this.gui;
this.logo_click_time = 0;
this.logo_click_count = 0;
this.previous_touch_y_coordinate = -1;
this.widget = {}; // contains references to subwidgets instances
this.cleanup_dom();
this.pos.ready.then(function(){
self.build_chrome();
self.build_widgets();
self.disable_rubberbanding();
self.disable_backpace_back();
self.ready.resolve();
self.loading_hide();
self.replace_crashmanager();
self.pos.push_order();
}).guardedCatch(function (err) { // error when loading models data from the backend
self.loading_error(err);
});
},
cleanup_dom: function() {
// remove default webclient handlers that induce click delay
$(document).off();
$(window).off();
$('html').off();
$('body').off();
// The above lines removed the bindings, but we really need them for the barcode
BarcodeEvents.start();
},
build_chrome: function() {
var self = this;
if ($.browser.chrome) {
var chrome_version = $.browser.version.split('.')[0];
if (parseInt(chrome_version, 10) >= 50) {
ajax.loadCSS('/point_of_sale/static/src/css/chrome50.css');
}
}
this.renderElement();
this.$('.pos-logo').click(function(){
self.click_logo();
});
if(this.pos.config.iface_big_scrollbars){
this.$el.addClass('big-scrollbars');
}
},
// displays a system error with the error-traceback
// popup.
show_error: function(error) {
this.gui.show_popup('error-traceback',{
'title': error.message,
'body': error.message + '\n' + error.data.debug + '\n',
});
},
// replaces the error handling of the existing crashmanager which
// uses jquery dialog to display the error, to use the pos popup
// instead
replace_crashmanager: function() {
var self = this;
CrashManager.include({
show_error: function(error) {
if (self.gui) {
self.show_error(error);
} else {
this._super(error);
}
},
});
},
click_logo: function() {
if (this.pos.debug) {
this.widget.debug.show();
} else {
var self = this;
var time = (new Date()).getTime();
var delay = 500;
if (this.logo_click_time + 500 < time) {
this.logo_click_time = time;
this.logo_click_count = 1;
} else {
this.logo_click_time = time;
this.logo_click_count += 1;
if (this.logo_click_count >= 6) {
this.logo_click_count = 0;
this.gui.sudo().then(function(){
self.widget.debug.show();
});
}
}
}
},
_scrollable: function(element, scrolling_down){
var $element = $(element);
var scrollable = true;
if (! scrolling_down && $element.scrollTop() <= 0) {
scrollable = false;
} else if (scrolling_down && $element.scrollTop() + $element.height() >= element.scrollHeight) {
scrollable = false;
}
return scrollable;
},
disable_rubberbanding: function(){
var self = this;
document.body.addEventListener('touchstart', function(event){
self.previous_touch_y_coordinate = event.touches[0].clientY;
});
// prevent the pos body from being scrollable.
document.body.addEventListener('touchmove',function(event){
var node = event.target;
var current_touch_y_coordinate = event.touches[0].clientY;
var scrolling_down;
if (current_touch_y_coordinate < self.previous_touch_y_coordinate) {
scrolling_down = true;
} else {
scrolling_down = false;
}
while(node){
if(node.classList && node.classList.contains('touch-scrollable') && self._scrollable(node, scrolling_down)){
return;
}
node = node.parentNode;
}
event.preventDefault();
});
},
// prevent backspace from performing a 'back' navigation
disable_backpace_back: function() {
$(document).on("keydown", function (e) {
if (e.which === 8 && !$(e.target).is("input, textarea")) {
e.preventDefault();
}
});
},
loading_error: function(err){
var self = this;
var title = err.message;
var body = err.stack;
if(err.message === 'XmlHttpRequestError '){
title = 'Network Failure (XmlHttpRequestError)';
body = 'The Point of Sale could not be loaded due to a network problem.\n Please check your internet connection.';
}else if(err.code === 200){
title = err.data.message;
body = err.data.debug;
}
if( typeof body !== 'string' ){
body = 'Traceback not available.';
}
var popup = $(QWeb.render('ErrorTracebackPopupWidget',{
widget: { options: {title: title , body: body }},
}));
popup.find('.button').click(function(){
self.gui.close();
});
popup.css({ zindex: 9001 });
popup.appendTo(this.$el);
},
loading_progress: function(fac){
this.$('.loader .loader-feedback').removeClass('oe_hidden');
this.$('.loader .progress').removeClass('oe_hidden').css({'width': ''+Math.floor(fac*100)+'%'});
},
loading_message: function(msg,progress){
this.$('.loader .loader-feedback').removeClass('oe_hidden');
this.$('.loader .message').text(msg);
if (typeof progress !== 'undefined') {
this.loading_progress(progress);
} else {
this.$('.loader .progress').addClass('oe_hidden');
}
},
loading_skip: function(callback){
if(callback){
this.$('.loader .loader-feedback').removeClass('oe_hidden');
this.$('.loader .button.skip').removeClass('oe_hidden');
this.$('.loader .button.skip').off('click');
this.$('.loader .button.skip').click(callback);
}else{
this.$('.loader .button.skip').addClass('oe_hidden');
}
},
loading_hide: function(){
var self = this;
this.$('.loader').animate({opacity:0},1500,'swing',function(){self.$('.loader').addClass('oe_hidden');});
},
loading_show: function(){
this.$('.loader').removeClass('oe_hidden').animate({opacity:1},150,'swing');
},
widgets: [
{
'name': 'order_selector',
'widget': OrderSelectorWidget,
'replace': '.placeholder-OrderSelectorWidget',
},{
'name': 'sale_details',
'widget': SaleDetailsButton,
'append': '.pos-rightheader',
'condition': function(){ return this.pos.proxy.printer; },
},{
'name': 'proxy_status',
'widget': ProxyStatusWidget,
'append': '.pos-rightheader',
'condition': function(){ return this.pos.config.use_proxy; },
},{
'name': 'screen_status',
'widget': ClientScreenWidget,
'append': '.pos-rightheader',
'condition': function(){ return this.pos.config.use_proxy; },
},{
'name': 'notification',
'widget': SynchNotificationWidget,
'append': '.pos-rightheader',
},{
'name': 'close_button',
'widget': HeaderButtonWidget,
'append': '.pos-rightheader',
'args': {
label: _t('Close'),
action: function(){
this.$el.addClass('close_button');
var self = this;
if (!this.confirmed) {
this.$el.addClass('confirm');
this.$el.text(_t('Confirm'));
this.confirmed = setTimeout(function(){
self.$el.removeClass('confirm');
self.$el.text(_t('Close'));
self.confirmed = false;
},2000);
} else {
clearTimeout(this.confirmed);
this.gui.close();
}
},
}
},{
'name': 'username',
'widget': UsernameWidget,
'replace': '.placeholder-UsernameWidget',
},{
'name': 'keyboard',
'widget': keyboard.OnscreenKeyboardWidget,
'replace': '.placeholder-OnscreenKeyboardWidget',
},{
'name': 'debug',
'widget': DebugWidget,
'append': '.pos-content',
},
],
load_widgets: function(widgets) {
for (var i = 0; i < widgets.length; i++) {
var widget = widgets[i];
if ( !widget.condition || widget.condition.call(this) ) {
var args = typeof widget.args === 'function' ? widget.args(this) : widget.args;
var w = new widget.widget(this, args || {});
if (widget.replace) {
w.replace(this.$(widget.replace));
} else if (widget.append) {
w.appendTo(this.$(widget.append));
} else if (widget.prepend) {
w.prependTo(this.$(widget.prepend));
} else {
w.appendTo(this.$el);
}
this.widget[widget.name] = w;
}
}
},
// This method instantiates all the screens, widgets, etc.
build_widgets: function() {
var self = this;
this.load_widgets(this.widgets);
this.screens = {};
var classe;
for (var i = 0; i < this.gui.screen_classes.length; i++) {
classe = this.gui.screen_classes[i];
if (!classe.condition || classe.condition.call(this)) {
var screen = new classe.widget(this,{});
screen.appendTo(this.$('.screens'));
this.screens[classe.name] = screen;
this.gui.add_screen(classe.name, screen);
}
}
this.popups = {};
_.forEach(this.gui.popup_classes, function (classe) {
if (!classe.condition || classe.condition.call(self)) {
var popup = new classe.widget(self,{});
popup.appendTo(self.$('.popups')).then(function () {
self.popups[classe.name] = popup;
self.gui.add_popup(classe.name, popup);
});
}
});
this.gui.set_startup_screen('products');
this.gui.set_default_screen('products');
},
destroy: function() {
this.pos.destroy();
this._super();
}
});
return {
Chrome: Chrome,
DebugWidget: DebugWidget,
HeaderButtonWidget: HeaderButtonWidget,
OrderSelectorWidget: OrderSelectorWidget,
ProxyStatusWidget: ProxyStatusWidget,
SaleDetailsButton: SaleDetailsButton,
ClientScreenWidget: ClientScreenWidget,
StatusWidget: StatusWidget,
SynchNotificationWidget: SynchNotificationWidget,
UsernameWidget: UsernameWidget,
};
});
@@ -0,0 +1,141 @@
odoo.define('point_of_sale.custom_hooks', function (require) {
'use strict';
const { Component } = owl;
const { onMounted, onPatched, onWillUnmount } = owl.hooks;
/**
* Introduce error handlers in the component.
*
* IMPROVEMENT: This is a terrible hook. There could be a better way to handle
* the error when the order failed to sync.
*/
function useErrorHandlers() {
const component = Component.current;
component._handlePushOrderError = async function (error) {
// This error handler receives `error` equivalent to `error.message` of the rpc error.
if (error.message === 'Backend Invoice') {
await this.showPopup('ConfirmPopup', {
title: this.env._t('Please print the invoice from the backend'),
body:
this.env._t(
'The order has been synchronized earlier. Please make the invoice from the backend for the order: '
) + error.data.order.name,
});
} else if (error.code < 0) {
// XmlHttpRequest Errors
const title = this.env._t('Unable to sync order');
const body = this.env._t(
'Check the internet connection then try to sync again by clicking on the red wifi button (upper right of the screen).'
);
await this.showPopup('OfflineErrorPopup', { title, body });
} else if (error.code === 200) {
// OpenERP Server Errors
await this.showPopup('ErrorTracebackPopup', {
title: error.data.message || this.env._t('Server Error'),
body:
error.data.debug ||
this.env._t('The server encountered an error while receiving your order.'),
});
} else {
// ???
await this.showPopup('ErrorPopup', {
title: this.env._t('Unknown Error'),
body: this.env._t(
'The order could not be sent to the server due to an unknown error'
),
});
}
};
}
function useAutoFocusToLast() {
const current = Component.current;
let target = null;
function autofocus() {
const prevTarget = target;
const allInputs = current.el.querySelectorAll('input');
target = allInputs[allInputs.length - 1];
if (target && target !== prevTarget) {
target.focus();
target.selectionStart = target.selectionEnd = target.value.length;
}
}
onMounted(autofocus);
onPatched(autofocus);
}
/**
* Use this hook when you want to do something on previously selected and
* newly selected order when the order changes.
*
* Normally, a component is rendered then the current order is changed. When
* this happens, we want to rerender the component because the new information
* should be reflected in the screen. Additionally, we might want to remove listeners
* to the previous order and attach listeners to the new one. This hook is
* perfect for the described situation.
*
* Internally, this hook performs the following:
* 1. call newOrderCB on mounted
* 2. listen to order changes and perform the following sequence:
* - call prevOrderCB(prevOrder)
* - call newOrderCB(newOrder)
* 3. call prevOrderCB on willUnmount
*
* @param {Function} prevOrderCB apply this callback on the previous order
* @param {Function} newOrderCB apply this callback on the new order
*/
function onChangeOrder(prevOrderCB, newOrderCB) {
const current = Component.current;
prevOrderCB = prevOrderCB ? prevOrderCB.bind(current) : () => {};
newOrderCB = newOrderCB ? newOrderCB.bind(current) : () => {};
onMounted(() => {
current.env.pos.on(
'change:selectedOrder',
async (pos, newOrder) => {
await prevOrderCB(pos.previous('selectedOrder'));
await newOrderCB(newOrder);
},
current
);
newOrderCB(current.env.pos.get_order());
});
onWillUnmount(() => {
current.env.pos.off('change:selectedOrder', null, current);
prevOrderCB(current.env.pos.get_order());
});
}
function useBarcodeReader(callbackMap, exclusive = false) {
const current = Component.current;
const barcodeReader = current.env.pos.barcode_reader;
for (let [key, callback] of Object.entries(callbackMap)) {
callbackMap[key] = callback.bind(current);
}
onMounted(() => {
if (barcodeReader) {
for (let key in callbackMap) {
if (exclusive) {
barcodeReader.set_exclusive_callback(key, callbackMap[key]);
} else {
barcodeReader.set_action_callback(key, callbackMap[key]);
}
}
}
});
onWillUnmount(() => {
if (barcodeReader) {
for (let key in callbackMap) {
if (exclusive) {
barcodeReader.remove_exclusive_callback(key, callbackMap[key]);
} else {
barcodeReader.remove_action_callback(key, callbackMap[key]);
}
}
}
});
}
return { useErrorHandlers, useAutoFocusToLast, onChangeOrder, useBarcodeReader };
});
+1
View File
@@ -180,6 +180,7 @@ var PosDB = core.Class.extend({
}
for(var i = 0, len = products.length; i < len; i++){
var product = products[i];
if (product.id in this.product_by_id) continue;
var search_string = utils.unaccent(this._product_search_string(product));
var categ_id = product.pos_categ_id ? product.pos_categ_id[0] : this.root_category_id;
product.product_tmpl_id = product.product_tmpl_id[0];
@@ -0,0 +1,20 @@
odoo.define('point_of_sale.DebugManager.Backend', function(require) {
'use strict';
const { _t } = require('web.core');
const DebugManager = require('web.DebugManager.Backend');
DebugManager.include({
/**
* Runs the JS (desktop) tests
*/
perform_pos_js_tests() {
this.do_action({
name: _t('JS Tests'),
target: 'new',
type: 'ir.actions.act_url',
url: '/pos/web/tests?mod=*',
});
},
});
});
-464
View File
@@ -1,464 +0,0 @@
odoo.define('point_of_sale.gui', function (require) {
"use strict";
// this file contains the Gui, which is the pos 'controller'.
// It contains high level methods to manipulate the interface
// such as changing between screens, creating popups, etc.
//
// it is available to all pos objects trough the '.gui' field.
var core = require('web.core');
var field_utils = require('web.field_utils');
var session = require('web.session');
var _t = core._t;
var Gui = core.Class.extend({
screen_classes: [],
popup_classes: [],
init: function(options){
var self = this;
this.pos = options.pos;
this.chrome = options.chrome;
this.screen_instances = {};
this.popup_instances = {};
this.default_screen = null;
this.startup_screen = null;
this.current_popup = null;
this.current_screen = null;
this.show_sync_errors = true;
this.chrome.ready.then(function(){
self.close_other_tabs();
self._show_first_screen();
self.pos.bind('change:selectedOrder', function(){
self.show_saved_screen(self.pos.get_order());
});
});
},
_show_first_screen: function() {
var order = this.pos.get_order();
if (order) {
this.show_saved_screen(order);
} else {
this.show_screen(this.startup_screen);
}
},
/* ---- Gui: SCREEN MANIPULATION ---- */
// register a screen widget to the gui,
// it must have been inserted into the dom.
add_screen: function(name, screen){
screen.hide();
this.screen_instances[name] = screen;
},
// sets the screen that will be displayed
// for new orders
set_default_screen: function(name){
this.default_screen = name;
},
// sets the screen that will be displayed
// when no orders are present
set_startup_screen: function(name) {
this.startup_screen = name;
},
// display the screen saved in an order,
// called when the user changes the current order
// no screen saved ? -> display default_screen
// no order ? -> display startup_screen
show_saved_screen: function(order,options) {
options = options || {};
this.close_popup();
if (order) {
this.show_screen(order.get_screen_data('screen') ||
options.default_screen ||
this.default_screen,
null,'refresh');
} else {
this.show_screen(this.startup_screen);
}
},
// display a screen.
// If there is an order, the screen will be saved in the order
// - params: used to load a screen with parameters, for
// example loading a 'product_details' screen for a specific product.
// - refresh: if you want the screen to cycle trough show / hide even
// if you are already on the same screen.
show_screen: function(screen_name,params,refresh,skip_close_popup) {
var screen = this.screen_instances[screen_name];
if (!screen) {
console.error("ERROR: show_screen("+screen_name+") : screen not found");
}
if (!skip_close_popup){
this.close_popup();
}
var order = this.pos.get_order();
if (order) {
var old_screen_name = order.get_screen_data('screen');
order.set_screen_data('screen',screen_name);
if(params){
order.set_screen_data('params',params);
}
if( screen_name !== old_screen_name ){
order.set_screen_data('previous-screen',old_screen_name);
}
}
if (refresh || screen !== this.current_screen) {
if (this.current_screen) {
this.current_screen.close();
this.current_screen.hide();
}
this.current_screen = screen;
this.current_screen.show(refresh);
}
},
// returns the current screen.
get_current_screen: function() {
return this.pos.get_order() ? ( this.pos.get_order().get_screen_data('screen') || this.default_screen ) : this.startup_screen;
},
// goes to the previous screen (as specified in the order). The history only
// goes 1 deep ...
back: function() {
var previous = this.pos.get_order().get_screen_data('previous-screen');
if (previous) {
this.show_screen(previous);
}
},
// returns the parameter specified when this screen was displayed
get_current_screen_param: function(param) {
if (this.pos.get_order()) {
var params = this.pos.get_order().get_screen_data('params');
return params ? params[param] : undefined;
} else {
return undefined;
}
},
/* ---- Gui: POPUP MANIPULATION ---- */
// registers a new popup in the GUI.
// the popup must have been previously inserted
// into the dom.
add_popup: function(name, popup) {
popup.hide();
this.popup_instances[name] = popup;
},
// displays a popup. Popup do not stack,
// are not remembered by the order, and are
// closed by screen changes or new popups.
show_popup: function(name,options) {
if (this.current_popup) {
this.close_popup();
}
this.current_popup = this.popup_instances[name];
return this.current_popup.show(options);
},
show_sync_error_popup: function() {
if (this.show_sync_errors) {
this.show_popup('error-sync',{
'title':_t('Changes could not be saved'),
'body': _t('You must be connected to the internet to save your changes.\n\n' +
'Orders that where not synced before will be synced next time you close an order while connected to the internet or when you close the session.'),
});
}
},
// close the current popup.
close_popup: function() {
if (this.current_popup) {
this.current_popup.close();
this.current_popup.hide();
this.current_popup = null;
}
},
// is there an active popup ?
has_popup: function() {
return !!this.current_popup;
},
/* ---- Gui: INTER TAB COMM ---- */
// This sets up automatic pos exit when open in
// another tab.
close_other_tabs: function() {
var self = this;
// avoid closing itself
var now = Date.now();
localStorage['message'] = '';
localStorage['message'] = JSON.stringify({
'message':'close_tabs',
'config': this.pos.config.id,
'window_uid': now,
});
// storage events are (most of the time) triggered only when the
// localstorage is updated in a different tab.
// some browsers (e.g. IE) does trigger an event in the same tab
// This may be a browser bug or a different interpretation of the HTML spec
// cf https://connect.microsoft.com/IE/feedback/details/774798/localstorage-event-fired-in-source-window
// Use window_uid parameter to exclude the current window
window.addEventListener("storage", function(event) {
var msg = event.data;
if ( event.key === 'message' && event.newValue) {
var msg = JSON.parse(event.newValue);
if ( msg.message === 'close_tabs' &&
msg.config == self.pos.config.id &&
msg.window_uid != now) {
console.info('POS / Session opened in another window. EXITING POS');
self._close();
}
}
}, false);
},
/* ---- Gui: ACCESS CONTROL ---- */
// A Generic UI that allow to select a user from a list.
// It returns a promise that resolves with the selected user
// upon success. Several options are available :
// - security: passwords will be asked
// - only_managers: restricts the list to managers
// - current_user: password will not be asked if this
// user is selected.
// - title: The title of the employee selection list.
// checks if the current user (or the user provided) has manager
// access rights. If not, a popup is shown allowing the user to
// temporarily login as an administrator.
// This method returns a promise, that succeeds with the
// manager user when the login is successfull.
sudo: function(user){
user = user || this.pos.get_cashier();
if (user.role === 'manager') {
return Promise.resolve(user);
} else {
return this.select_employee({
security: true,
only_managers: true,
title: _t('Login as a Manager'),
});
}
},
/* ---- Gui: CLOSING THE POINT OF SALE ---- */
close: function() {
var self = this;
var pending = this.pos.db.get_orders().length;
if (!pending) {
this._close();
} else {
var always = function () {
var pending = self.pos.db.get_orders().length;
if (!pending) {
self._close();
} else {
var reason = self.pos.get('failed') ?
_t('Some orders could not be submitted to '+
'the server due to configuration errors. '+
'You can exit the Point of Sale, but do '+
'not close the session before the issue '+
'has been resolved.') :
_t('Some orders could not be submitted to '+
'the server due to internet connection issues. '+
'You can exit the Point of Sale, but do '+
'not close the session before the issue '+
'has been resolved.');
self.show_popup('confirm', {
'title': _t('Offline Orders'),
'body': reason,
'confirm': function() {
self._close();
},
});
}
};
this.pos.push_order().then(always, always);
}
},
_close: function() {
var self = this;
this.chrome.loading_show();
this.chrome.loading_message(_t('Closing ...'));
this.pos.push_order().then(function(){
window.location = "/web#action=point_of_sale.action_client_pos_menu";
});
},
/* ---- Gui: SOUND ---- */
play_sound: function(sound) {
var src = '';
if (sound === 'error') {
src = "/point_of_sale/static/src/sounds/error.wav";
} else if (sound === 'bell') {
src = "/point_of_sale/static/src/sounds/bell.wav";
} else {
console.error('Unknown sound: ',sound);
return;
}
$('body').append('<audio src="'+src+'" autoplay="true"></audio>');
},
/* ---- Gui: FILE I/O ---- */
// This will make the browser download 'contents' as a
// file named 'name'
// if 'contents' is not a string, it is converted into
// a JSON representation of the contents.
// TODO: remove me in master: deprecated in favor of prepare_download_link
// this method is kept for backward compatibility but is likely not going
// to work as many browsers do to not accept fake click events on links
download_file: function(contents, name) {
href_params = this.prepare_file_blob(contents,name);
var evt = document.createEvent("HTMLEvents");
evt.initEvent("click");
$("<a>",href_params).get(0).dispatchEvent(evt);
},
prepare_download_link: function(contents, filename, src, target) {
var href_params = this.prepare_file_blob(contents, filename);
$(target).parent().attr(href_params);
$(src).addClass('oe_hidden');
$(target).removeClass('oe_hidden');
// hide again after click
$(target).click(function() {
$(src).removeClass('oe_hidden');
$(this).addClass('oe_hidden');
});
},
prepare_file_blob: function(contents, name) {
var URL = window.URL || window.webkitURL;
if (typeof contents !== 'string') {
contents = JSON.stringify(contents,null,2);
}
var blob = new Blob([contents]);
return {download: name || 'document.txt',
href: URL.createObjectURL(blob),}
},
/* ---- Gui: EMAILS ---- */
// This will launch the user's email software
// with a new email with the address, subject and body
// prefilled.
send_email: function(address, subject, body) {
window.open("mailto:" + address +
"?subject=" + (subject ? window.encodeURIComponent(subject) : '') +
"&body=" + (body ? window.encodeURIComponent(body) : ''));
},
/* ---- Gui: KEYBOARD INPUT ---- */
// This is a helper to handle numpad keyboard input.
// - buffer: an empty or number string
// - input: '[0-9],'+','-','.','CLEAR','BACKSPACE'
// - options: 'firstinput' -> will clear buffer if
// input is '[0-9]' or '.'
// returns the new buffer containing the modifications
// (the original is not touched)
numpad_input: function(buffer, input, options) {
var newbuf = buffer.slice(0);
options = options || {};
var newbuf_float = newbuf === '-' ? newbuf : field_utils.parse.float(newbuf);
var decimal_point = _t.database.parameters.decimal_point;
if (input === decimal_point) {
if (options.firstinput) {
newbuf = "0.";
}else if (!newbuf.length || newbuf === '-') {
newbuf += "0.";
} else if (newbuf.indexOf(decimal_point) < 0){
newbuf = newbuf + decimal_point;
}
} else if (input === 'CLEAR') {
newbuf = "";
} else if (input === 'BACKSPACE') {
newbuf = newbuf.substring(0,newbuf.length - 1);
} else if (input === '+') {
if ( newbuf[0] === '-' ) {
newbuf = newbuf.substring(1,newbuf.length);
}
} else if (input === '-') {
if (options.firstinput) {
newbuf = '-0';
} else if ( newbuf[0] === '-' ) {
newbuf = newbuf.substring(1,newbuf.length);
} else {
newbuf = '-' + newbuf;
}
} else if (input[0] === '+' && !isNaN(parseFloat(input))) {
newbuf = this.chrome.format_currency_no_symbol(newbuf_float + parseFloat(input));
} else if (!isNaN(parseInt(input))) {
if (options.firstinput) {
newbuf = '' + input;
} else {
newbuf += input;
}
}
if (newbuf === "-") {
newbuf = "";
}
// End of input buffer at 12 characters.
if (newbuf.length > buffer.length && newbuf.length > 12) {
this.play_sound('bell');
return buffer.slice(0);
}
return newbuf;
},
});
var define_screen = function (classe) {
Gui.prototype.screen_classes.push(classe);
};
var define_popup = function (classe) {
Gui.prototype.popup_classes.push(classe);
};
return {
Gui: Gui,
define_screen: define_screen,
define_popup: define_popup,
};
});
+24 -5
View File
@@ -1,10 +1,29 @@
odoo.define('web.web_client', function (require) {
'use strict';
odoo.define('point_of_sale.main', function (require) {
"use strict";
const env = require('web.env');
const WebClient = require('web.AbstractWebClient');
const Chrome = require('point_of_sale.Chrome');
const Registries = require('point_of_sale.Registries');
const { configureGui } = require('point_of_sale.Gui');
var chrome = require('point_of_sale.chrome');
var core = require('web.core');
owl.config.mode = env.isDebug() ? 'dev' : 'prod';
owl.Component.env = env;
core.action_registry.add('pos.ui', chrome.Chrome);
async function startPosApp(webClient) {
Registries.Component.freeze();
await env.session.is_bound;
env.qweb.addTemplates(env.session.owlTemplates);
await owl.utils.whenReady();
await webClient.setElement(document.body);
await webClient.start();
const chrome = new (Registries.Component.get(Chrome))(null, { webClient });
await chrome.mount(document.querySelector('.o_action_manager'));
await chrome.start();
configureGui({ component: chrome });
}
const webClient = new WebClient();
startPosApp(webClient);
return webClient;
});
+259 -208
View File
@@ -1,7 +1,6 @@
odoo.define('point_of_sale.models', function (require) {
"use strict";
var ajax = require('web.ajax');
var BarcodeParser = require('barcodes.BarcodeParser');
var BarcodeReader = require('point_of_sale.BarcodeReader');
var PosDB = require('point_of_sale.DB');
@@ -10,8 +9,6 @@ var concurrency = require('web.concurrency');
var config = require('web.config');
var core = require('web.core');
var field_utils = require('web.field_utils');
var rpc = require('web.rpc');
var session = require('web.session');
var time = require('web.time');
var utils = require('web.utils');
@@ -34,12 +31,17 @@ var exports = {};
// 'pos' and is available to all widgets extending PosWidget.
exports.PosModel = Backbone.Model.extend({
initialize: function(session, attributes) {
initialize: function(attributes) {
Backbone.Model.prototype.initialize.call(this, attributes);
var self = this;
this.flush_mutex = new Mutex(); // used to make sure the orders are sent to the server once at time
this.chrome = attributes.chrome;
this.gui = attributes.gui;
this.rpc = this.get('rpc');
this.session = this.get('session');
this.do_action = this.get('do_action');
this.setLoadingMessage = this.get('setLoadingMessage');
this.setLoadingProgress = this.get('setLoadingProgress');
this.showLoadingSkip = this.get('showLoadingSkip');
this.proxy = new devices.ProxyDevice(this); // used to communicate to the hardware devices via a local proxy
this.barcode_reader = new BarcodeReader({'pos': this, proxy:this.proxy});
@@ -73,14 +75,15 @@ exports.PosModel = Backbone.Model.extend({
// these dynamic attributes can be watched for change by other models or widgets
this.set({
'synch': { state:'connected', pending:0 },
'synch': { status:'connected', pending:0 },
'orders': new OrderCollection(),
'selectedOrder': null,
'selectedClient': null,
'cashier': null,
'selectedCategoryId': null,
});
this.get('orders').bind('remove', function(order,_unused_,options){
this.get('orders').on('remove', function(order,_unused_,options){
self.on_removed_order(order,options.index,options.reason);
});
@@ -89,8 +92,8 @@ exports.PosModel = Backbone.Model.extend({
var order = self.get_order();
this.set('selectedClient', order ? order.get_client() : null );
}
this.get('orders').bind('add remove change', update_client, this);
this.bind('change:selectedOrder', update_client, this);
this.get('orders').on('add remove change', update_client, this);
this.on('change:selectedOrder', update_client, this);
// We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched,
// Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched.
@@ -123,14 +126,14 @@ exports.PosModel = Backbone.Model.extend({
var self = this;
return new Promise(function (resolve, reject) {
self.barcode_reader.disconnect_from_proxy();
self.chrome.loading_message(_t('Connecting to the IoT Box'), 0);
self.chrome.loading_skip(function () {
self.setLoadingMessage(_t('Connecting to the IoT Box'), 0);
self.showLoadingSkip(function () {
self.proxy.stop_searching();
});
self.proxy.autoconnect({
force_ip: self.config.proxy_ip || undefined,
progress: function(prog){
self.chrome.loading_progress(prog);
self.setLoadingProgress(prog);
},
}).then(
function () {
@@ -140,16 +143,20 @@ exports.PosModel = Backbone.Model.extend({
resolve();
},
function (statusText, url) {
var show_loading_error = (self.gui.current_screen === null);
resolve();
if (show_loading_error && statusText == 'error' && window.location.protocol == 'https:') {
self.gui.show_popup('alert', {
// this should reject so that it can be captured when we wait for pos.ready
// in the chrome component.
// then, if it got really rejected, we can show the error.
if (statusText == 'error' && window.location.protocol == 'https:') {
reject({
title: _t('HTTPS connection to IoT Box failed'),
body: _.str.sprintf(
_t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'),
url
),
popup: 'alert',
});
} else {
resolve();
}
}
);
@@ -166,7 +173,7 @@ exports.PosModel = Backbone.Model.extend({
{
label: 'version',
loaded: function (self) {
return session.rpc('/web/webclient/version_info',{}).then(function (version) {
return self.session.rpc('/web/webclient/version_info',{}).then(function (version) {
self.version = version;
});
},
@@ -174,7 +181,7 @@ exports.PosModel = Backbone.Model.extend({
},{
model: 'res.company',
fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'state_id', 'tax_calculation_rounding_method'],
ids: function(self){ return [session.user_context.allowed_company_ids[0]]; },
ids: function(self){ return [self.session.user_context.allowed_company_ids[0]]; },
loaded: function(self,companies){ self.company = companies[0]; },
},{
model: 'decimal.precision',
@@ -247,7 +254,7 @@ exports.PosModel = Backbone.Model.extend({
});
return new Promise(function (resolve, reject) {
var tax_ids = _.pluck(self.taxes, 'id');
rpc.query({
self.rpc({
model: 'account.tax',
method: 'get_real_tax_amount',
args: [tax_ids],
@@ -311,7 +318,7 @@ exports.PosModel = Backbone.Model.extend({
return true;
}
});
if (user.id === session.uid) {
if (user.id === self.session.uid) {
self.user = user;
self.employee.name = user.name;
self.employee.role = user.role;
@@ -382,7 +389,7 @@ exports.PosModel = Backbone.Model.extend({
},
},{
model: 'pos.category',
fields: ['id', 'name', 'parent_id', 'child_id'],
fields: ['id', 'name', 'parent_id', 'child_id', 'write_date'],
domain: function(self) {
return self.config.limit_categories && self.config.iface_available_categ_ids.length ? [['id', 'in', self.config.iface_available_categ_ids]] : [];
},
@@ -393,7 +400,7 @@ exports.PosModel = Backbone.Model.extend({
model: 'product.product',
fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id',
'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description',
'product_tmpl_id','tracking'],
'product_tmpl_id','tracking', 'write_date'],
order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }),
domain: function(self){
var domain = ['&', '&', ['sale_ok','=',true],['available_in_pos','=',true],'|',['company_id','=',self.config.company_id[0]],['company_id','=',false]];
@@ -416,6 +423,7 @@ exports.PosModel = Backbone.Model.extend({
product.lst_price = round_pr(product.lst_price * conversion_rate, self.currency.rounding);
}
product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]});
product.pos = self;
return new exports.Product({}, product);
}));
},
@@ -533,7 +541,7 @@ exports.PosModel = Backbone.Model.extend({
reject();
};
self.company_logo.crossOrigin = "anonymous";
self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + session.db + '&_' + Math.random();
self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + self.session.db + '&_' + Math.random();
});
},
}, {
@@ -559,7 +567,7 @@ exports.PosModel = Backbone.Model.extend({
resolve();
} else {
var model = self.models[index];
self.chrome.loading_message(_t('Loading')+' '+(model.label || model.model || ''), progress);
self.setLoadingMessage(_t('Loading')+' '+(model.label || model.model || ''), progress);
var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true;
if (!cond) {
@@ -577,7 +585,7 @@ exports.PosModel = Backbone.Model.extend({
if( model.model ){
var params = {
model: model.model,
context: _.extend(context, session.user_context || {}),
context: _.extend(context, self.session.user_context || {}),
};
if (model.ids) {
@@ -590,7 +598,7 @@ exports.PosModel = Backbone.Model.extend({
params.orderBy = order;
}
rpc.query(params).then(function (result) {
self.rpc(params).then(function (result) {
try { // catching exceptions in model.loaded(...)
Promise.resolve(model.loaded(self, result, tmp))
.then(function () { load_model(index + 1); },
@@ -637,7 +645,7 @@ exports.PosModel = Backbone.Model.extend({
return new Promise(function (resolve, reject) {
var fields = _.find(self.models, function(model){ return model.label === 'load_partners'; }).fields;
var domain = self.prepare_new_partners_domain();
rpc.query({
self.rpc({
model: 'res.partner',
method: 'search_read',
args: [domain, fields],
@@ -649,7 +657,7 @@ exports.PosModel = Backbone.Model.extend({
if (self.db.add_partners(partners)) { // check if the partners we got were real updates
resolve();
} else {
reject();
reject('Failed in updating partners.');
}
}, function (type, err) { reject(); });
});
@@ -819,7 +827,7 @@ exports.PosModel = Backbone.Model.extend({
if (order) {
order.get_orderlines().forEach(function (orderline) {
var product = orderline.product;
var image_url = window.location.origin + '/web/image?model=product.product&field=image_128&id=' + product.id;
var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`;
// only download and convert image if we haven't done it before
if (! product.image_base64) {
@@ -832,18 +840,18 @@ exports.PosModel = Backbone.Model.extend({
return Promise.all(get_image_promises).then(function () {
var rendered_order_lines = "";
var rendered_payment_lines = "";
var order_total_with_tax = self.chrome.format_currency(0);
var order_total_with_tax = self.format_currency(0);
if (order) {
rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', {
'orderlines': order.get_orderlines(),
'widget': self.chrome,
'pos': self,
});
rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', {
'order': order,
'widget': self.chrome,
'pos': self,
});
order_total_with_tax = self.chrome.format_currency(order.get_total_with_tax());
order_total_with_tax = self.format_currency(order.get_total_with_tax());
}
var $rendered_html = $(rendered_html);
@@ -870,7 +878,7 @@ exports.PosModel = Backbone.Model.extend({
// saves the order locally and try to send it to the backend.
// it returns a promise that succeeds after having tried to send the order and all the other pending orders.
push_order: function (order, opts) {
push_orders: function (order, opts) {
opts = opts || {};
var self = this;
@@ -889,6 +897,22 @@ exports.PosModel = Backbone.Model.extend({
});
},
push_single_order: function (order, opts) {
opts = opts || {};
const self = this;
const order_id = self.db.add_order(order.export_as_JSON());
return new Promise(function (resolve, reject) {
self.flush_mutex.exec(function () {
var flushed = self._flush_orders([self.db.get_order(order_id)], opts);
flushed.then(resolve, reject);
return flushed;
});
});
},
// saves the order locally and try to send it to the backend and make an invoice
// returns a promise that succeeds when the order has been posted and successfully generated
// an invoice. This method can fail in various ways:
@@ -926,7 +950,7 @@ exports.PosModel = Backbone.Model.extend({
transfer.then(function(order_server_id){
// generate the pdf and download it
if (order_server_id.length && !order.is_to_email()) {
self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{
self.do_action('point_of_sale.pos_invoice_report',{additional_context:{
active_ids:order_server_id,
}}).then(function () {
resolveInvoiced(order_server_id);
@@ -956,6 +980,7 @@ exports.PosModel = Backbone.Model.extend({
},
// wrapper around the _save_to_server that updates the synch status widget
// Resolves to the backend ids of the synced orders.
_flush_orders: function(orders, options) {
var self = this;
this.set_synch('connecting', orders.length);
@@ -969,12 +994,12 @@ exports.PosModel = Backbone.Model.extend({
});
},
set_synch: function(state, pending) {
if (['connected', 'connecting', 'error', 'disconnected'].indexOf(state) === -1) {
console.error(state, ' is not a known connection state.');
set_synch: function(status, pending) {
if (['connected', 'connecting', 'error', 'disconnected'].indexOf(status) === -1) {
console.error(status, ' is not a known connection state.');
}
pending = pending || this.db.get_orders().length + this.db.get_ids_to_remove_from_server().length;
this.set('synch', { state: state, pending: pending });
this.set('synch', { status, pending });
},
// send an array of orders to the server
@@ -1004,11 +1029,11 @@ exports.PosModel = Backbone.Model.extend({
return order;
})];
args.push(options.draft || false);
return rpc.query({
return this.rpc({
model: 'pos.order',
method: 'create_from_ui',
args: args,
kwargs: {context: session.user_context},
kwargs: {context: this.session.user_context},
}, {
timeout: timeout,
shadow: !options.to_invoice
@@ -1021,18 +1046,14 @@ exports.PosModel = Backbone.Model.extend({
return server_ids;
}).catch(function (reason){
var error = reason.message;
console.warn('Failed to send orders:', orders);
if(error.code === 200 ){ // Business Logic Error, not a connection problem
// Hide error if already shown before ...
if ((!self.get('failed') || options.show_error) && !options.to_invoice) {
self.gui.show_popup('error-traceback',{
'title': error.data.message,
'body': error.data.debug
});
self.set('failed',error);
throw error;
}
self.set('failed',error);
}
console.warn('Failed to send orders:', orders);
self.gui.show_sync_error_popup();
throw error;
});
},
@@ -1053,11 +1074,11 @@ exports.PosModel = Backbone.Model.extend({
var self = this;
var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length;
return rpc.query({
return this.rpc({
model: 'pos.order',
method: 'remove_from_ui',
args: [server_ids],
kwargs: {context: session.user_context},
kwargs: {context: this.session.user_context},
}, {
timeout: timeout,
shadow: true,
@@ -1066,8 +1087,17 @@ exports.PosModel = Backbone.Model.extend({
self.db.set_ids_removed_from_server(server_ids);
return server_ids;
}).catch(function (reason){
self.gui.show_sync_error_popup();
console.error('Failed to remove orders:', server_ids);
var 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:', server_ids);
throw error;
});
},
@@ -1137,7 +1167,7 @@ exports.PosModel = Backbone.Model.extend({
this.db.add_order(json.paid_orders[i].data);
}
report.paid = json.paid_orders.length;
this.push_order();
this.push_orders();
}
if (json.unpaid_orders) {
@@ -1212,6 +1242,62 @@ exports.PosModel = Backbone.Model.extend({
},
electronic_payment_interfaces: {},
format_currency: function(amount, precision) {
var currency =
this && this.currency
? this.currency
: { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 };
amount = this.format_currency_no_symbol(amount, precision, currency);
if (currency.position === 'after') {
return amount + ' ' + (currency.symbol || '');
} else {
return (currency.symbol || '') + ' ' + amount;
}
},
format_currency_no_symbol: function(amount, precision, currency) {
if (!currency) {
currency =
this && this.currency
? this.currency
: { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 };
}
var decimals = currency.decimals;
if (precision && this.dp[precision] !== undefined) {
decimals = this.dp[precision];
}
if (typeof amount === 'number') {
amount = round_di(amount, decimals).toFixed(decimals);
amount = field_utils.format.float(round_di(amount, decimals), {
digits: [69, decimals],
});
}
return amount;
},
format_pr: function(value, precision) {
var decimals =
precision > 0
? Math.max(0, Math.ceil(Math.log(1.0 / precision) / Math.log(10)))
: 0;
return value.toFixed(decimals);
},
/**
* (value = 1.0000, decimals = 2) => '1'
* (value = 1.1234, decimals = 2) => '1.12'
* @param {number} value amount to format
*/
formatFixed: function(value) {
const currency = this.currency || { decimals: 2 };
return `${Number(value.toFixed(currency.decimals || 0))}`;
},
});
/**
@@ -1324,7 +1410,21 @@ exports.Product = Backbone.Model.extend({
initialize: function(attr, options){
_.extend(this, options);
},
isAllowOnlyOneLot: function() {
const productUnit = this.get_unit();
return this.tracking === 'lot' || !productUnit || !productUnit.is_pos_groupable;
},
get_unit: function() {
var unit_id = this.uom_id;
if(!unit_id){
return undefined;
}
unit_id = unit_id[0];
if(!this.pos){
return undefined;
}
return this.pos.units_by_id[unit_id];
},
// Port of get_product_price on product.pricelist.
//
// Anything related to UOM can be ignored, the POS will always use
@@ -1474,6 +1574,57 @@ exports.Orderline = Backbone.Model.extend({
orderline.price_manually_set = this.price_manually_set;
return orderline;
},
getPackLotLinesToEdit: function(isAllowOnlyOneLot) {
const currentPackLotLines = this.pack_lot_lines.models;
let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length;
nExtraLines = nExtraLines > 0 ? nExtraLines : 1;
const tempLines = currentPackLotLines
.map(lotLine => ({
id: lotLine.cid,
text: lotLine.get('lot_name'),
}))
.concat(
Array.from(Array(nExtraLines)).map(_ => ({
text: '',
}))
);
return isAllowOnlyOneLot ? [tempLines[0]] : tempLines;
},
/**
* @param { modifiedPackLotLines, newPackLotLines }
* @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name)
* @param {Array} newPackLotLines array of { lot_name: String }
*/
setPackLotLines: function({ modifiedPackLotLines, newPackLotLines }) {
// Set the new values for modified lot lines.
let lotLinesToRemove = [];
for (let lotLine of this.pack_lot_lines.models) {
const modifiedLotName = modifiedPackLotLines[lotLine.cid];
if (modifiedLotName) {
lotLine.set({ lot_name: modifiedLotName });
} else {
// We should not call lotLine.remove() here because
// we don't want to mutate the array while looping thru it.
lotLinesToRemove.push(lotLine);
}
}
// Remove those that needed to be removed.
for (let lotLine of lotLinesToRemove) {
lotLine.remove();
}
// Create new pack lot lines.
let newPackLotLine;
for (let newLotLine of newPackLotLines) {
newPackLotLine = new exports.Packlotline({}, { order_line: this });
newPackLotLine.set({ lot_name: newLotLine.lot_name });
this.pack_lot_lines.add(newPackLotLine);
}
// Set the quantity of the line based on number of pack lots.
this.pack_lot_lines.set_quantity_by_lot();
},
set_product_lot: function(product){
this.has_product_lot = product.tracking !== 'none';
this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null, {'order_line': this});
@@ -1552,24 +1703,6 @@ exports.Orderline = Backbone.Model.extend({
return lots_required;
},
compute_lot_lines: function(){
var pack_lot_lines = this.pack_lot_lines;
var lines = pack_lot_lines.length;
var lots_required = this.get_required_number_of_lots();
if(lots_required > lines){
for(var i=0; i<lots_required - lines; i++){
pack_lot_lines.add(new exports.Packlotline({}, {'order_line': this}));
}
}
if(lots_required < lines){
var to_remove = lines - lots_required;
var lot_lines = pack_lot_lines.sortBy('lot_name').slice(0, to_remove);
pack_lot_lines.remove(lot_lines);
}
return this.pack_lot_lines;
},
has_valid_product_lot: function(){
if(!this.has_product_lot){
return true;
@@ -1580,15 +1713,7 @@ exports.Orderline = Backbone.Model.extend({
// return the unit of measure of the product
get_unit: function(){
var unit_id = this.product.uom_id;
if(!unit_id){
return undefined;
}
unit_id = unit_id[0];
if(!this.pos){
return undefined;
}
return this.pos.units_by_id[unit_id];
return this.product.get_unit();
},
// return the product of this orderline
get_product: function(){
@@ -1597,7 +1722,9 @@ exports.Orderline = Backbone.Model.extend({
// selects or deselects this orderline
set_selected: function(selected){
this.selected = selected;
// this trigger also triggers the change event of the collection.
this.trigger('change',this);
this.trigger('new-orderline-selected');
},
// returns true if this orderline is selected
is_selected: function(){
@@ -1648,6 +1775,7 @@ exports.Orderline = Backbone.Model.extend({
//used to create a json of the ticket, to be sent to the printer
export_for_printing: function(){
return {
id: this.id,
quantity: this.get_quantity(),
unit_name: this.get_unit().name,
price: this.get_unit_display_price(),
@@ -2115,14 +2243,6 @@ var PacklotlineCollection = Backbone.Collection.extend({
this.order_line = options.order_line;
},
get_empty_model: function(){
return this.findWhere({'lot_name': null});
},
remove_empty_model: function(){
this.remove(this.where({'lot_name': null}));
},
get_valid_lots: function(){
return this.filter(function(model){
return model.get('lot_name');
@@ -2130,13 +2250,11 @@ var PacklotlineCollection = Backbone.Collection.extend({
},
set_quantity_by_lot: function() {
if (this.order_line.product.tracking == 'serial') {
var valid_lots_quantity = this.get_valid_lots().length;
if (this.order_line.quantity < 0){
valid_lots_quantity = -valid_lots_quantity;
}
this.order_line.set_quantity(valid_lots_quantity);
var valid_lots_quantity = this.get_valid_lots().length;
if (this.order_line.quantity < 0){
valid_lots_quantity = -valid_lots_quantity;
}
this.order_line.set_quantity(valid_lots_quantity);
}
});
@@ -2174,7 +2292,7 @@ exports.Paymentline = Backbone.Model.extend({
set_amount: function(value){
this.order.assert_editable();
this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals);
this.pos.send_current_order_to_customer_facing_display();
if (this.pos.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display();
this.trigger('change',this);
},
// returns the amount of money on this paymentline
@@ -2242,6 +2360,7 @@ exports.Paymentline = Backbone.Model.extend({
//exports as JSON for receipt printing
export_for_printing: function(){
return {
cid: this.cid,
amount: this.get_amount(),
payment_method: this.payment_method.name,
};
@@ -2455,7 +2574,7 @@ exports.Order = Backbone.Model.extend({
qweb.default_dict = _.clone(QWeb.default_dict);
qweb.add_template('<templates><t t-name="subreceipt">'+subreceipt+'</t></templates>');
return qweb.render('subreceipt',{'pos':self.pos,'widget':self.pos.chrome,'order':self, 'receipt': receipt}) ;
return qweb.render('subreceipt',{'pos':self.pos,'order':self, 'receipt': receipt}) ;
}
}
@@ -2644,9 +2763,6 @@ exports.Order = Backbone.Model.extend({
}
this.assert_editable();
options = options || {};
var attr = JSON.parse(JSON.stringify(product));
attr.pos = this.pos;
attr.order = this;
var line = new exports.Orderline({}, {pos: this.pos, order: this, product: product});
this.fix_tax_included_price(line);
@@ -2687,8 +2803,8 @@ exports.Order = Backbone.Model.extend({
this.select_orderline(this.get_last_orderline());
}
if(line.has_product_lot){
this.display_lot_popup();
if (options.draftPackLotLines) {
this.selected_orderline.setPackLotLines(options.draftPackLotLines);
}
if (this.pos.config.iface_customer_facing_display) {
this.pos.send_current_order_to_customer_facing_display();
@@ -2700,6 +2816,10 @@ exports.Order = Backbone.Model.extend({
select_orderline: function(line){
if(line){
if(line !== this.selected_orderline){
// if line (new line to select) is not the same as the old
// selected_orderline, then we set the old line to false,
// and set the new line to true. Also, set the new line as
// the selected_orderline.
if(this.selected_orderline){
this.selected_orderline.set_selected(false);
}
@@ -2717,19 +2837,6 @@ exports.Order = Backbone.Model.extend({
}
},
display_lot_popup: function() {
var order_line = this.get_selected_orderline();
if (order_line){
var pack_lot_lines = order_line.compute_lot_lines();
this.pos.gui.show_popup('packlotline', {
'title': _t('Lot/Serial Number(s) Required'),
'pack_lot_lines': pack_lot_lines,
'order_line': order_line,
'order': this,
});
}
},
/* ---- Payment Lines --- */
add_paymentline: function(payment_method) {
this.assert_editable();
@@ -2739,7 +2846,7 @@ exports.Order = Backbone.Model.extend({
};
this.paymentlines.add(newPaymentline);
this.select_paymentline(newPaymentline);
return newPaymentline;
},
get_paymentlines: function(){
return this.paymentlines.models;
@@ -3064,105 +3171,49 @@ exports.Order = Backbone.Model.extend({
wait_for_push_order: function () {
return this.is_to_email();
},
/**
* @returns {Object} object to use as props for instantiating OrderReceipt.
*/
getOrderReceiptEnv: function() {
// Formerly get_receipt_render_env defined in ScreenWidget.
return {
order: this,
receipt: this.export_for_printing(),
orderlines: this.get_orderlines(),
paymentlines: this.get_paymentlines(),
};
},
updatePricelist: function(newClient) {
let newClientPricelist, newClientFiscalPosition;
const defaultFiscalPosition = this.pos.fiscal_positions.find(
(position) => position.id === this.pos.config.default_fiscal_position_id[0]
);
if (newClient) {
newClientFiscalPosition = newClient.property_account_position_id
? this.pos.fiscal_positions.find(
(position) => position.id === newClient.property_account_position_id[0]
)
: defaultFiscalPosition;
newClientPricelist =
this.pos.pricelists.find(
(pricelist) => pricelist.id === newClient.property_product_pricelist[0]
) || this.pos.default_pricelist;
} else {
newClientFiscalPosition = defaultFiscalPosition;
newClientPricelist = this.pos.default_pricelist;
}
this.fiscal_position = newClientFiscalPosition;
this.set_pricelist(newClientPricelist);
}
});
var OrderCollection = Backbone.Collection.extend({
model: exports.Order,
});
/*
The numpad handles both the choice of the property currently being modified
(quantity, price or discount) and the edition of the corresponding numeric value.
*/
exports.NumpadState = Backbone.Model.extend({
defaults: {
buffer: "0",
mode: "quantity"
},
appendNewChar: function(newChar) {
var oldBuffer;
oldBuffer = this.get('buffer');
if (oldBuffer === '0') {
this.set({
buffer: newChar
});
} else if (oldBuffer === '-0') {
this.set({
buffer: "-" + newChar
});
} else if (!(newChar === '.') || oldBuffer.indexOf('.') === -1) {
this.set({
buffer: (this.get('buffer')) + newChar
});
}
this.trigger('set_value',this.get('buffer'));
},
deleteLastChar: function() {
if(this.get('buffer') === ""){
if(this.get('mode') === 'quantity'){
this.trigger('set_value','remove');
}else{
this.trigger('set_value',this.get('buffer'));
}
} else if (this.get('buffer') === "-0") {
this.set({ buffer: "0" });
this.trigger('set_value',this.get('buffer'));
}else{
var newBuffer = this.get('buffer').slice(0,-1) || "";
this.set({ buffer: newBuffer });
this.trigger('set_value',this.get('buffer'));
}
},
switchSign: function() {
var oldBuffer;
oldBuffer = this.get('buffer');
this.set({
buffer: oldBuffer[0] === '-' ? oldBuffer.substr(1) : "-" + oldBuffer
});
this.trigger('set_value',this.get('buffer'));
},
positiveSign: function() {
var oldBuffer;
oldBuffer = this.get('buffer');
if (oldBuffer[0] === '-'){
this.set({buffer: oldBuffer.substr(1)});
this.trigger('set_value',this.get('buffer'));
}
},
negativeSign: function() {
var oldBuffer;
oldBuffer = this.get('buffer');
if (oldBuffer[0] !== '-'){
this.set({buffer: "-" + oldBuffer});
this.trigger('set_value',this.get('buffer'));
}
},
changeMode: function(newMode) {
this.set({
buffer: "0",
mode: newMode
});
},
reset: function() {
this.set({
buffer: "0",
mode: "quantity"
});
},
resetValue: function(){
if(this.get('buffer') === "") {
this.trigger('set_value','remove');
}
else {
this.set({ buffer: "" });
this.trigger('set_value',this.get('buffer'));
}
},
});
// exports = {
// PosModel: PosModel,
// NumpadState: NumpadState,
// load_fields: load_fields,
// load_models: load_models,
// Orderline: Orderline,
@@ -1,343 +0,0 @@
odoo.define('point_of_sale.popups', function (require) {
"use strict";
// This file contains the Popups.
// Popups must be loaded and named in chrome.js.
// They are instanciated / destroyed with the .gui.show_popup()
// and .gui.close_popup() methods.
var PosBaseWidget = require('point_of_sale.BaseWidget');
var gui = require('point_of_sale.gui');
var _t = require('web.core')._t;
var PopupWidget = PosBaseWidget.extend({
template: 'PopupWidget',
init: function(parent, args) {
this._super(parent, args);
this.options = {};
},
events: {
'click .button.cancel': 'click_cancel',
'click .button.confirm': 'click_confirm',
'click .selection-item': 'click_item',
'click .input-button': 'click_numpad',
'click .mode-button': 'click_numpad',
},
// show the popup !
show: function(options){
if(this.$el){
this.$el.removeClass('oe_hidden');
}
if (typeof options === 'string') {
this.options = {title: options};
} else {
this.options = options || {};
}
this.renderElement();
// popups block the barcode reader ...
if (this.pos.barcode_reader) {
this.pos.barcode_reader.save_callbacks();
this.pos.barcode_reader.reset_action_callbacks();
}
},
// called before hide, when a popup is closed.
// extend this if you want a custom action when the
// popup is closed.
close: function(){
if (this.pos.barcode_reader) {
this.pos.barcode_reader.restore_callbacks();
}
},
// hides the popup. keep in mind that this is called in
// the initialization pass of the pos instantiation,
// so you don't want to do anything fancy in here
hide: function(){
if (this.$el) {
this.$el.addClass('oe_hidden');
}
},
// what happens when we click cancel
// ( it should close the popup and do nothing )
click_cancel: function(){
this.gui.close_popup();
if (this.options.cancel) {
this.options.cancel.call(this);
}
},
// what happens when we confirm the action
click_confirm: function(){
this.gui.close_popup();
if (this.options.confirm) {
this.options.confirm.call(this);
}
},
// Since Widget does not support extending the events declaration
// we declared them all in the top class.
click_item: function(){},
click_numad: function(){},
});
gui.define_popup({name:'alert', widget: PopupWidget});
var ErrorPopupWidget = PopupWidget.extend({
template:'ErrorPopupWidget',
show: function(options){
this._super(options);
this.gui.play_sound('error');
},
});
gui.define_popup({name:'error', widget: ErrorPopupWidget});
var SyncErrorPopupWidget = ErrorPopupWidget.extend({
template:'SyncErrorPopupWidget',
show: function(opts) {
var self = this;
this._super(opts);
this.$('.stop_showing_sync_errors').off('click').click(function(){
self.gui.show_sync_errors = false;
self.click_confirm();
});
}
});
gui.define_popup({name:'error-sync', widget: SyncErrorPopupWidget});
var ErrorTracebackPopupWidget = ErrorPopupWidget.extend({
template:'ErrorTracebackPopupWidget',
show: function(opts) {
var self = this;
this._super(opts);
this.$('.download').off('click').click(function(){
self.gui.prepare_download_link(self.options.body,
_t('error') + ' ' + moment().format('YYYY-MM-DD-HH-mm-ss') + '.txt',
'.download', '.download_error_file');
});
this.$('.email').off('click').click(function(){
self.gui.send_email( self.pos.company.email,
_t('IMPORTANT: Bug Report From Odoo Point Of Sale'),
self.options.body);
});
}
});
gui.define_popup({name:'error-traceback', widget: ErrorTracebackPopupWidget});
var ErrorBarcodePopupWidget = ErrorPopupWidget.extend({
template:'ErrorBarcodePopupWidget',
show: function(barcode){
this._super({barcode: barcode});
},
});
gui.define_popup({name:'error-barcode', widget: ErrorBarcodePopupWidget});
var ConfirmPopupWidget = PopupWidget.extend({
template: 'ConfirmPopupWidget',
});
gui.define_popup({name:'confirm', widget: ConfirmPopupWidget});
/**
* A popup that allows the user to select one item from a list.
*
* Example::
*
* show_popup('selection',{
* title: "Popup Title",
* list: [
* { label: 'foobar', item: 45 },
* { label: 'bar foo', item: 'stuff' },
* ],
* confirm: function(item) {
* // get the item selected by the user.
* },
* cancel: function(){
* // user chose nothing
* }
* });
*/
var SelectionPopupWidget = PopupWidget.extend({
template: 'SelectionPopupWidget',
show: function(options){
var self = this;
options = options || {};
this._super(options);
this.list = options.list || [];
this.is_selected = options.is_selected || function (item) { return false; };
this.renderElement();
},
click_item : function(event) {
this.gui.close_popup();
if (this.options.confirm) {
var item = this.list[parseInt($(event.target).data('item-index'))];
item = item ? item.item : item;
this.options.confirm.call(self,item);
}
}
});
gui.define_popup({name:'selection', widget: SelectionPopupWidget});
var TextInputPopupWidget = PopupWidget.extend({
template: 'TextInputPopupWidget',
show: function(options){
options = options || {};
this._super(options);
this.renderElement();
this.$('input,textarea').focus();
},
click_confirm: function(){
var value = this.$('input,textarea').val();
this.gui.close_popup();
if( this.options.confirm ){
this.options.confirm.call(this,value);
}
},
});
gui.define_popup({name:'textinput', widget: TextInputPopupWidget});
var TextAreaPopupWidget = TextInputPopupWidget.extend({
template: 'TextAreaPopupWidget',
});
gui.define_popup({name:'textarea', widget: TextAreaPopupWidget});
var PackLotLinePopupWidget = PopupWidget.extend({
template: 'PackLotLinePopupWidget',
events: _.extend({}, PopupWidget.prototype.events, {
'click .remove-lot': 'remove_lot',
'keydown': 'add_lot',
'blur .packlot-line-input': 'lose_input_focus'
}),
show: function(options){
this._super(options);
this.focus();
},
click_confirm: function(){
var pack_lot_lines = this.options.pack_lot_lines;
this.$('.packlot-line-input').each(function(index, el){
var cid = $(el).attr('cid'),
lot_name = $(el).val();
var pack_line = pack_lot_lines.get({cid: cid});
pack_line.set_lot_name(lot_name);
});
pack_lot_lines.remove_empty_model();
pack_lot_lines.set_quantity_by_lot();
this.options.order.save_to_db();
this.options.order_line.trigger('change', this.options.order_line);
this.gui.close_popup();
},
add_lot: function(ev) {
if (ev.keyCode === $.ui.keyCode.ENTER && this.options.order_line.product.tracking == 'serial'){
var pack_lot_lines = this.options.pack_lot_lines,
$input = $(ev.target),
cid = $input.attr('cid'),
lot_name = $input.val();
var lot_model = pack_lot_lines.get({cid: cid});
lot_model.set_lot_name(lot_name); // First set current model then add new one
if(!pack_lot_lines.get_empty_model()){
var new_lot_model = lot_model.add();
this.focus_model = new_lot_model;
}
pack_lot_lines.set_quantity_by_lot();
this.renderElement();
this.focus();
}
},
remove_lot: function(ev){
var pack_lot_lines = this.options.pack_lot_lines,
$input = $(ev.target).prev(),
cid = $input.attr('cid');
var lot_model = pack_lot_lines.get({cid: cid});
lot_model.remove();
pack_lot_lines.set_quantity_by_lot();
this.renderElement();
},
lose_input_focus: function(ev){
var $input = $(ev.target),
cid = $input.attr('cid');
var lot_model = this.options.pack_lot_lines.get({cid: cid});
lot_model.set_lot_name($input.val());
},
focus: function(){
this.$("input[autofocus]").focus();
this.focus_model = false; // after focus clear focus_model on widget
}
});
gui.define_popup({name:'packlotline', widget:PackLotLinePopupWidget});
var NumberPopupWidget = PopupWidget.extend({
template: 'NumberPopupWidget',
show: function(options){
options = options || {};
this._super(options);
this.inputbuffer = '' + (options.value || '');
this.decimal_separator = _t.database.parameters.decimal_point;
this.renderElement();
this.firstinput = true;
},
click_numpad: function(event){
var newbuf = this.gui.numpad_input(
this.inputbuffer,
$(event.target).data('action'),
{'firstinput': this.firstinput});
this.firstinput = (newbuf.length === 0);
if (newbuf !== this.inputbuffer) {
this.inputbuffer = newbuf;
this.$('.value').text(this.inputbuffer);
}
},
click_confirm: function(){
this.gui.close_popup();
if( this.options.confirm ){
this.options.confirm.call(this,this.inputbuffer);
}
},
});
gui.define_popup({name:'number', widget: NumberPopupWidget});
var PasswordPopupWidget = NumberPopupWidget.extend({
renderElement: function(){
this._super();
this.$('.popup').addClass('popup-password');
},
click_numpad: function(event){
this._super.apply(this, arguments);
var $value = this.$('.value');
$value.text($value.text().replace(/./g, '•'));
},
});
gui.define_popup({name:'password', widget: PasswordPopupWidget});
var OrderImportPopupWidget = PopupWidget.extend({
template: 'OrderImportPopupWidget',
});
gui.define_popup({name:'orderimport', widget: OrderImportPopupWidget});
return PopupWidget;
});
+60 -14
View File
@@ -5,31 +5,75 @@ var Session = require('web.Session');
var core = require('web.core');
var _t = core._t;
// IMPROVEMENT: This is too much. We can get away from this class.
class PrintResult {
constructor({ successful, message }) {
this.successful = successful;
this.message = message;
}
}
class PrintResultGenerator {
IoTActionError() {
return new PrintResult({
successful: false,
message: {
title: _t('Connection to IoT Box failed'),
body: _t('Please check if the IoT Box is still connected.'),
},
});
}
IoTResultError() {
return new PrintResult({
successful: false,
message: {
title: _t('Connection to the printer failed'),
body: _t('Please check if the printer is still connected.'),
},
});
}
Successful() {
return new PrintResult({
successful: true,
});
}
}
var PrinterMixin = {
init: function () {
init: function() {
this.receipt_queue = [];
this.printResultGenerator = new PrintResultGenerator();
},
/**
* Add the receipt to the queue of receipts to be printed and process it.
* We clear the print queue if printing is not successful.
* @param {String} receipt: The receipt to be printed, in HTML
* @returns {PrintResult}
*/
print_receipt: function (receipt) {
var self = this;
print_receipt: async function(receipt) {
if (receipt) {
this.receipt_queue.push(receipt);
}
function process_next_job() {
if (self.receipt_queue.length > 0) {
var r = self.receipt_queue.shift();
return self.htmlToImg(r)
.then(self.send_printing_job.bind(self))
.then(self._onIoTActionResult.bind(self))
.then(process_next_job)
.guardedCatch(self._onIoTActionFail.bind(self));
let image, sendPrintResult;
while (this.receipt_queue.length > 0) {
receipt = this.receipt_queue.shift();
image = await this.htmlToImg(receipt);
try {
sendPrintResult = await this.send_printing_job(image);
} catch (error) {
// Error in communicating to the IoT box.
this.receipt_queue.length = 0;
return this.printResultGenerator.IoTActionError();
}
// rpc call is okay but printing failed because
// IoT box can't find a printer.
if (!sendPrintResult) {
this.receipt_queue.length = 0;
return this.printResultGenerator.IoTResultError();
}
}
return process_next_job();
return this.printResultGenerator.Successful();
},
/**
@@ -64,7 +108,7 @@ var PrinterMixin = {
_onIoTActionResult: function (data){
if (this.pos && (data === false || data.result === false)) {
this.pos.gui.show_popup('error',{
Gui.showPopup('ErrorPopup',{
'title': _t('Connection to the printer failed'),
'body': _t('Please check if the printer is still connected.'),
});
@@ -73,7 +117,7 @@ var PrinterMixin = {
_onIoTActionFail: function () {
if (this.pos) {
this.pos.gui.show_popup('error',{
Gui.showPopup('ErrorPopup',{
'title': _t('Connection to IoT Box failed'),
'body': _t('Please check if the IoT Box is still connected.'),
});
@@ -120,5 +164,7 @@ var Printer = core.Class.extend(PrinterMixin, {
return {
PrinterMixin: PrinterMixin,
Printer: Printer,
PrintResult,
PrintResultGenerator,
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
odoo.define('point_of_sale.utils', function(require) {
'use strict';
function getFileAsText(file) {
return new Promise((resolve, reject) => {
if (!file) {
reject();
} else {
const reader = new FileReader();
reader.addEventListener('load', function() {
resolve(reader.result);
});
reader.addEventListener('abort', reject);
reader.addEventListener('error', reject);
reader.readAsText(file);
}
});
}
return { getFileAsText };
});
@@ -1,83 +0,0 @@
odoo.define('point_of_sale.BaseWidget', function (require) {
"use strict";
var field_utils = require('web.field_utils');
var utils = require('web.utils');
var Widget = require('web.Widget');
var round_di = utils.round_decimals;
// This is a base class for all Widgets in the POS. It exposes relevant data to the
// templates :
// - widget.currency : { symbol: '$' | '€' | ..., position: 'before' | 'after }
// - widget.format_currency(amount) : this method returns a formatted string based on the
// symbol, the position, and the amount of money.
// if the PoS is not fully loaded when you instanciate the widget, the currency might not
// yet have been initialized. Use __build_currency_template() to recompute with correct values
// before rendering.
var PosBaseWidget = Widget.extend({
init:function(parent,options){
this._super(parent);
options = options || {};
this.pos = options.pos || (parent ? parent.pos : undefined);
this.chrome = options.chrome || (parent ? parent.chrome : undefined);
this.gui = options.gui || (parent ? parent.gui : undefined);
// the widget class does not support anymore using $el/el before the
// 'start' lifecycle method, but point of sale actually needs it.
this.setElement(this._makeDescriptive());
},
format_currency: function(amount,precision){
var currency = (this.pos && this.pos.currency) ? this.pos.currency : {symbol:'$', position: 'after', rounding: 0.01, decimals: 2};
amount = this.format_currency_no_symbol(amount,precision);
if (currency.position === 'after') {
return amount + ' ' + (currency.symbol || '');
} else {
return (currency.symbol || '') + ' ' + amount;
}
},
format_currency_no_symbol: function(amount, precision) {
var currency = (this.pos && this.pos.currency) ? this.pos.currency : {symbol:'$', position: 'after', rounding: 0.01, decimals: 2};
var decimals = currency.decimals;
if (precision && this.pos.dp[precision] !== undefined) {
decimals = this.pos.dp[precision];
}
if (typeof amount === 'number') {
amount = round_di(amount,decimals).toFixed(decimals);
amount = field_utils.format.float(round_di(amount, decimals), {digits: [69, decimals]});
}
return amount;
},
show: function(){
this.$el.removeClass('oe_hidden');
},
hide: function(){
this.$el.addClass('oe_hidden');
},
format_pr: function(value,precision){
var decimals = precision > 0 ? Math.max(0,Math.ceil(Math.log(1.0/precision) / Math.log(10))) : 0;
return value.toFixed(decimals);
},
format_fixed: function(value,integer_width,decimal_width){
value = value.toFixed(decimal_width || 0);
var width = value.indexOf('.');
if (width < 0 ) {
width = value.length;
}
var missing = integer_width - width;
while (missing > 0) {
value = '0' + value;
missing--;
}
return value;
},
});
return PosBaseWidget;
});
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Chrome" owl="1">
<div class="pos" t-att-class="{ 'big-scrollbars': state.uiState.hasBigScrollBars }">
<div class="pos-receipt-print"></div>
<t t-if="state.uiState === 'READY'">
<div class="pos-topheader">
<div t-if="tempScreen.isShown" class="block-top-header" />
<div class="pos-branding">
<img class="pos-logo" t-on-click="trigger('toggle-debug-widget')"
src="/point_of_sale/static/src/img/logo.png" alt="Logo" />
</div>
<div class="pos-rightheader">
<OrderSelector />
<CashierName />
<SaleDetailsButton t-if="env.pos.proxy.printer" />
<ProxyStatus t-if="env.pos.config.use_proxy" />
<ClientScreenButton t-if="clientScreenButtonIsShown" />
<SyncNotification />
<HeaderButton />
</div>
</div>
<div class="pos-content">
<div class="window">
<div class="subwindow">
<div class="subwindow-container">
<div class="subwindow-container-fix screens">
<t isShown="!tempScreen.isShown" t-component="mainScreen.component"
t-props="mainScreenProps" t-key="mainScreen.name" />
<t t-if="tempScreen.isShown" t-component="tempScreen.component"
t-props="tempScreenProps" t-key="tempScreen.name" />
</div>
</div>
</div>
</div>
<DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown"
t-transition="fade" />
</div>
</t>
<div t-if="['LOADING', 'CLOSING'].includes(state.uiState)" class="loader" t-transition="swing">
<div class="loader-feedback">
<h1 class="message">
<t t-esc="loading.message" />
</h1>
<div class="progressbar">
<div class="progress" t-ref="progressbar"></div>
</div>
<div t-if="loading.skipButtonIsShown" class="button skip" t-on-click="trigger('loading-skip-callback')">
Skip
</div>
</div>
</div>
<!-- Allow popups to be visible at any state of the ui. -->
<div t-if="popup.isShown" class="popups">
<t t-component="popup.component" t-props="popupProps"
t-key="popup.name" />
</div>
<NotificationSound t-if="state.sound.src" sound="state.sound" />
</div>
</t>
<t t-name="CustomerFacingDisplayHead">
<div class="resources">
<base t-att-href="origin" />
<link href="/point_of_sale/static/src/css/customer_facing_display.css"
rel="stylesheet" />
<script type="text/javascript">
// This function needs to be named that way, call it the foreign JS API
// The iotbox will execute it, with the behavior intended
function foreign_js() {
if ($('.pos-adv').hasClass('pos-hidden')) {
$('.pos-customer_facing_display').addClass('pos-js_no_ADV');
}
$(window).on('resize', function () {
$('.pos-customer_facing_display').toggleClass('pos-js_no_ADV', $('.pos-adv').hasClass('pos-hidden'));
}).trigger('resize');
};
</script>
</div>
</t>
<t t-name="CustomerFacingDisplayOrderLines">
<t t-foreach="orderlines" t-as="orderline">
<div class="pos_orderlines_item">
<div>
<div t-attf-style="background-image:url(#{orderline.product.image_base64})" />
</div>
<div>
<t t-esc="orderline.product.display_name" />
</div>
<div>
<t t-esc="orderline.get_quantity_str()" />
</div>
<div>
<t t-esc="pos.format_currency(orderline.get_display_price())" />
</div>
</div>
</t>
</t>
<t t-name="CustomerFacingDisplayPaymentLines">
<t t-foreach="order.get_paymentlines()" t-as="paymentline">
<div>
<span>
<t t-esc="paymentline.name" /></span>
</div>
<div>
<span>
<t t-esc="pos.format_currency(paymentline.get_amount())" /></span>
</div>
</t>
<t t-if="order.get_paymentlines().length > 0">
<div>
<span class="pos-change_title">Change:</span>
</div>
<div>
<span class="pos-change_amount">
<t t-esc="pos.format_currency(order.get_change())" /></span>
</div>
</t>
</t>
</templates>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="CashierName" owl="1">
<div class="oe_status">
<span class="username">
<t t-esc="username" />
</span>
</div>
</t>
</templates>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ClientScreenButton" owl="1">
<div class="oe_status" t-on-click="onClick">
<span class="message"><t t-esc="message" /></span>
<div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange">
<i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Warning" title="Client Screen Warning"></i>
</div>
<div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red">
<i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Disconnected" title="Client Screen Disconnected"></i>
</div>
<div t-if="state.status === 'success'" class="js_connected oe_icon oe_green">
<i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Connected" title="Client Screen Connected"></i>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="DebugWidget" owl="1">
<Draggable limitArea="'.pos'">
<div class="debug-widget">
<header class="drag-handle">
<h1>Debug Window</h1>
</header>
<div class="toggle" t-on-click="trigger('toggle-debug-widget')" title="Dismiss"
role="img" aria-label="Dismiss"><i class="fa fa-times" /></div>
<div class="content">
<p class="category">Electronic Scale</p>
<ul>
<li>
<input t-model="state.weightInput" type="text" class="weight"></input>
</li>
<li class="button set_weight" t-on-click="setWeight">Set Weight</li>
<li class="button reset_weight" t-on-click="resetWeight">Reset</li>
</ul>
<p class="category">Barcode Scanner</p>
<ul>
<li>
<input t-model="state.barcodeInput" type="text" class="ean"></input>
</li>
<li class="button barcode" t-on-click="barcodeScan">Scan</li>
<li class="button custom_ean" t-on-click="barcodeScanEAN">Scan EAN-13</li>
</ul>
<p class="category">Orders</p>
<ul>
<li class="button" t-on-click="deleteOrders">
Delete Paid Orders
</li>
<li class="button" t-on-click="deleteUnpaidOrders">
Delete Unpaid Orders
</li>
<li t-if="!state.isPaidOrdersReady" class="button"
t-on-click="preparePaidOrders">
Export Paid Orders
</li>
<a t-else="" t-att-download="paidOrdersFilename" t-att-href="paidOrdersURL"
t-on-click="state.isPaidOrdersReady = !state.isPaidOrdersReady">
<li class="button">
Download Paid Orders
</li>
</a>
<li t-if="!state.isUnpaidOrdersReady" class="button"
t-on-click="prepareUnpaidOrders">
Export Unpaid Orders
</li>
<a t-else="" t-att-download="unpaidOrdersFilename"
t-att-href="unpaidOrdersURL"
t-on-click="state.isUnpaidOrdersReady = !state.isUnpaidOrdersReady">
<li class="button">
Download Unpaid Orders
</li>
</a>
<li class="button import_orders" style="position:relative">
Import Orders
<input t-on-change="importOrders" type="file"
style="opacity:0;position:absolute;top:0;left:0;right:0;bottom:0;margin:0;cursor:pointer" />
</li>
</ul>
<p class="category">Hardware Status</p>
<ul>
<li class="status weighing">Weighing</li>
<li class="button display_refresh" t-on-click="refreshDisplay">
Refresh Display
</li>
</ul>
<p class="category">Hardware Events</p>
<ul>
<li class="event" t-ref="open_cashbox">Open Cashbox</li>
<li class="event" t-ref="print_receipt">Print Receipt</li>
<li class="event" t-ref="scale_read">Read Weighing Scale</li>
</ul>
<p class="category">Others</p>
<ul>
<li class="event">
<span>Buffer: </span>
<t t-esc="bufferRepr" />
</li>
</ul>
</div>
</div>
</Draggable>
</t>
</templates>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="HeaderButton" owl="1">
<div class="header-button close_button" t-att-class="{ confirm: state.label === 'Confirm' }"
t-on-click="onClick">
<t t-esc="translatedLabel" />
</div>
</t>
</templates>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderSelector" owl="1">
<div class="order-selector">
<span class="orders touch-scrollable">
<t t-foreach="orderList" t-as="order" t-key="order.cid">
<t t-if="order === env.pos.get_order()">
<span class="order-button select-order selected">
<span class="order-sequence">
<t t-esc="order.sequence_number" />
</span>
<t t-esc="creationTime(order)" />
</span>
</t>
<t t-else="">
<span class="order-button select-order" t-on-click="selectOrder(order)">
<span class="order-sequence">
<t t-esc="order.sequence_number" />
</span>
</span>
</t>
</t>
</span>
<span class="order-button square neworder-button" t-on-click="addNewOrder">
<i class="fa fa-plus" role="img" aria-label="New order" title="New order" />
</span>
<span class="order-button square deleteorder-button" t-on-click="deleteCurrentOrder">
<i class="fa fa-minus" role="img" aria-label="Delete order" title="Delete order" />
</span>
</div>
</t>
</templates>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ProxyStatus" owl="1">
<div class="oe_status js_proxy" t-on-click="onClick">
<span t-if="state.msg" class="js_msg">
<t t-esc="state.msg" />
</span>
<div t-if="state.status === 'connected'" class="js_connected oe_green">
<i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Connected"
title="Proxy Connected"></i>
</div>
<div t-if="state.status === 'connecting'" class="js_connecting">
<i class="fa fa-fw fa-spin fa-spinner" role="img" aria-label="Connecting to Proxy"
title="Connecting to Proxy"></i>
</div>
<div t-if="state.status === 'warning'" class="js_warning oe_orange">
<i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Warning"
title="Proxy Warning"></i>
</div>
<div t-if="state.status === 'disconnected'" class="js_disconnected oe_red">
<i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Disconnected"
title="Proxy Disconnected"></i>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SaleDetailsButton" owl="1">
<div class="oe_status">
<div class="js_connected oe_icon">
<i class="fa fa-fw fa-print" role="img" aria-label="Print" t-on-click="onClick"
title="Print a report with all the sales of the current PoS Session"></i>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SyncNotification" owl="1">
<div class="oe_status" t-on-click="onClick">
<span t-if="state.msg" class="js_msg">
<t t-esc="state.msg" />
<span> </span>
</span>
<div t-if="state.status === 'connected'" class="js_connected oe_icon oe_green">
<i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Connected"
title="Synchronisation Connected"></i>
</div>
<div t-if="state.status === 'connecting'" class="js_connecting oe_icon">
<i class="fa fa-fw fa-spin fa-spinner" role="img"
aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i>
</div>
<div t-if="state.status === 'disconnected'" class="js_disconnected oe_icon oe_red">
<i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Disconnected"
title="Synchronisation Disconnected"></i>
</div>
<div t-if="state.status === 'error'" class="js_error oe_icon oe_red">
<i class="fa fa-fw fa-warning" role="img" aria-label="Synchronisation Error"
title="Synchronisation Error"></i>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Draggable" owl="1">
<t t-slot="default"></t>
</t>
</templates>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="NotificationSound" owl="1">
<audio t-att-src="props.sound.src" autoplay="true"></audio>
</t>
</templates>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ConfirmPopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-confirm">
<header class="title drag-handle">
<t t-esc="props.title" />
</header>
<main class="body">
<t t-esc=" props.body" />
</main>
<footer class="footer">
<div class="button confirm" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.cancelText" />
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="EditListInput" owl="1">
<div>
<input type="text" t-model="props.item.text" class="popup-input list-line-input"
placeholder="Serial/Lot Number" t-on-keyup="onKeyup" />
<i class="oe_link_icon fa fa-trash-o" role="img" aria-label="Remove" title="Remove"
t-on-click="trigger('remove-item', props.item)"></i>
</div>
</t>
</templates>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="EditListPopup" owl="1">
<div role="dialog" class="modal-dialog">
<div class="popup popup-text">
<header class="title">
<t t-esc="props.title" />
</header>
<main class="list-lines" t-on-remove-item="removeItem"
t-on-create-new-item="createNewItem">
<t t-foreach="state.array" t-as="item" t-key="item._id">
<EditListInput item="item" />
</t>
</main>
<footer class="footer">
<div class="button confirm" t-on-click="confirm">
Ok
</div>
<div class="button cancel" t-on-click="cancel">
Cancel
</div>
</footer>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ErrorBarcodePopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-barcode">
<header class="title drag-handle">
<span>Unknown Barcode</span>
<br />
<span class="barcode">
<t t-esc="props.code" />
</span>
</header>
<main class="body">
<t t-esc="translatedMessage" />
</main>
<footer class="footer">
<div class="button cancel" t-on-click="confirm">
Ok
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ErrorPopup" owl="1">
<div role="dialog" class="modal-dialog">
<div class="popup popup-error">
<p class="title">
<t t-esc="props.title" />
</p>
<p class="body">
<t t-esc="props.body" />
</p>
<div class="footer">
<div class="button cancel" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ErrorTracebackPopup" owl="1">
<div role="dialog" class="modal-dialog">
<div class="popup popup-error">
<header class="title">
<t t-esc="props.title" />
</header>
<main class="body traceback">
<t t-esc="props.body" />
</main>
<footer class="footer">
<div t-if="!props.exitButtonIsShown" class="button cancel" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
<div t-if="props.exitButtonIsShown" class="button cancel" t-on-click="trigger(props.exitButtonTrigger)">
<t t-esc="props.exitButtonText" />
</div>
<a t-att-download="tracebackFilename" t-att-href="tracebackUrl">
<div class="button icon download">
<i class="fa fa-download" role="img"
aria-label="Download error traceback"
title="Download error traceback"></i>
</div>
</a>
<div class="button icon email" t-on-click="emailTraceback">
<i class="fa fa-paper-plane" role="img" aria-label="Send by email"
title="Send by email"></i>
</div>
</footer>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="NumberPopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-number" t-att-class="{ 'popup-password': props.isPassword }">
<header class="title drag-handle">
<t t-esc="props.title" />
</header>
<div class="popup-input value active">
<t t-esc="inputBuffer" />
</div>
<div class="popup-numpad">
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('1')">1</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('2')">2</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('3')">3</button>
<t t-if="props.cheap">
<button class="mode-button add" t-on-mousedown.prevent="sendInput('+1')">+1</button>
</t>
<t t-if="!props.cheap">
<button class="mode-button add" t-on-mousedown.prevent="sendInput('+10')">+10</button>
</t>
<br />
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('4')">4</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('5')">5</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('6')">6</button>
<t t-if="props.cheap">
<button class="mode-button add" t-on-mousedown.prevent="sendInput('+2')">+2</button>
</t>
<t t-if="!props.cheap">
<button class="mode-button add" t-on-mousedown.prevent="sendInput('+20')">+20</button>
</t>
<br />
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('7')">7</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('8')">8</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('9')">9</button>
<t t-if="props.cheap">
<button class="mode-button add" t-on-mousedown.prevent="sendInput('+5')">+5</button>
</t>
<t t-if="!props.cheap">
<button class="mode-button add" t-on-mousedown.prevent="sendInput('+50')">+50</button>
</t>
<br />
<button class="input-button numpad-char" t-on-mousedown.prevent="sendInput('Delete')">C</button>
<button class="input-button number-char" t-on-mousedown.prevent="sendInput('0')">0</button>
<button class="input-button number-char dot" t-on-mousedown.prevent="sendInput(decimalSeparator)">
<t t-esc="decimalSeparator" /></button>
<button class="input-button numpad-backspace" t-on-mousedown.prevent="sendInput('Backspace')">
<img style="pointer-events: none;"
src="/point_of_sale/static/src/img/backspace.png" width="24"
height="21" alt="Backspace" />
</button>
<br />
</div>
<footer class="footer centered">
<div class="button cancel" t-on-mousedown.prevent="cancel">
<t t-esc="props.cancelText" />
</div>
<div class="button confirm" t-on-mousedown.prevent="confirm">
<t t-esc="props.confirmText" />
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OfflineErrorPopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-error">
<header class="title drag-handle">
<t t-esc="props.title" />
</header>
<main class="body traceback"><t t-esc="props.body"/></main>
<footer class="footer">
<div class="button cancel" t-on-click="cancel">
Ok
</div>
<div class="button dont-show-again" t-on-click="dontShowAgain">
Don't show again
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderImportPopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-import">
<header class="title drag-handle">
<span>Finished Importing Orders</span>
</header>
<ul class="body">
<li>Successfully imported <b><t t-esc="props.report.paid or 0" /></b> paid orders</li>
<li>Successfully imported <b><t t-esc="props.report.unpaid or 0" /></b> unpaid orders</li>
<t t-if="unpaidSkipped">
<li><b><t t-esc="unpaidSkipped"/></b> unpaid orders could not be imported
<ul>
<li><b><t t-esc="props.report.unpaid_skipped_existing or 0" /></b> were duplicates of existing orders</li>
<li><b><t t-esc="props.report.unpaid_skipped_session or 0" /></b> belong to another session:
<t t-if="props.report.unpaid_skipped_sessions">
<ul>
<li>Session ids: <b><t t-esc="props.report.unpaid_skipped_sessions" /></b></li>
</ul>
</t>
</li>
</ul>
</li>
</t>
</ul>
<footer class="footer">
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.confirmText" />
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="SelectionPopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-selection">
<header class="title drag-handle">
<t t-esc="props.title" />
</header>
<div class="selection scrollable-y touch-scrollable">
<t t-foreach="props.list" t-as="item" t-key="item._id">
<div class="selection-item" t-att-class="{ selected: item.isSelected }"
t-on-click="selectItem(item.id)">
<t t-esc="item.label" />
</div>
</t>
</div>
<footer class="footer">
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.cancelText" />
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="TextAreaPopup" owl="1">
<div role="dialog" class="modal-dialog">
<Draggable>
<div class="popup popup-textarea">
<header class="title drag-handle">
<t t-esc="props.title" />
</header>
<textarea t-model="state.inputValue" t-ref="input"></textarea>
<footer class="footer">
<div class="button confirm" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.cancelText" />
</div>
</footer>
</div>
</Draggable>
</div>
</t>
</templates>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="TextInputPopup" owl="1">
<div role="dialog" class="modal-dialog">
<div class="popup popup-textinput">
<header class="title">
<t t-esc="props.title" />
</header>
<div class="div">
<p>
<t t-esc="props.body" />
</p>
<input type="text" t-model="state.inputValue" t-ref="input" />
</div>
<div class="footer">
<div class="button confirm" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.cancelText" />
</div>
</div>
</div>
</div>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More