[REF] point_of_sale: migration to OWL framework
This commit is contained in:
committed by
Pierre Masereel
parent
cbd6360570
commit
c6397ab3b3
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
+23
@@ -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;
|
||||
});
|
||||
+80
@@ -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;
|
||||
});
|
||||
+67
@@ -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;
|
||||
});
|
||||
+29
@@ -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 };
|
||||
});
|
||||
@@ -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=*',
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user