mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 16:12:04 +02:00
Initial commit: Sale packages
This commit is contained in:
commit
14e3d26998
6469 changed files with 2479670 additions and 0 deletions
|
|
@ -0,0 +1,517 @@
|
|||
odoo.define('point_of_sale.Chrome', function(require) {
|
||||
'use strict';
|
||||
|
||||
const { loadCSS } = require('@web/core/assets');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const BarcodeParser = require('barcodes.BarcodeParser');
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const NumberBuffer = require('point_of_sale.NumberBuffer');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
|
||||
const { identifyError, batched } = require('point_of_sale.utils');
|
||||
const { odooExceptionTitleMap } = require("@web/core/errors/error_dialogs");
|
||||
const { ConnectionLostError, ConnectionAbortedError, RPCError } = require('@web/core/network/rpc_service');
|
||||
const { useBus } = require("@web/core/utils/hooks");
|
||||
const { debounce } = require("@web/core/utils/timing");
|
||||
const { Transition } = require("@web/core/transition");
|
||||
|
||||
const {
|
||||
onError,
|
||||
onMounted,
|
||||
onWillDestroy,
|
||||
useExternalListener,
|
||||
useRef,
|
||||
useState,
|
||||
useSubEnv,
|
||||
reactive,
|
||||
} = owl;
|
||||
|
||||
/**
|
||||
* Chrome is the root component of the PoS App.
|
||||
*/
|
||||
class Chrome extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useExternalListener(window, 'beforeunload', this._onBeforeUnload);
|
||||
useListener('show-main-screen', this.__showScreen);
|
||||
useListener('toggle-debug-widget', debounce(this._toggleDebugWidget, 100));
|
||||
useListener('toggle-mobile-searchbar', this._toggleMobileSearchBar);
|
||||
useListener('show-temp-screen', this.__showTempScreen);
|
||||
useListener('close-temp-screen', this.__closeTempScreen);
|
||||
useListener('close-pos', this._closePos);
|
||||
useListener('loading-skip-callback', () => this.env.proxy.stop_searching());
|
||||
useListener('play-sound', this._onPlaySound);
|
||||
useListener('set-sync-status', this._onSetSyncStatus);
|
||||
useListener('show-notification', this._onShowNotification);
|
||||
useListener('close-notification', this._onCloseNotification);
|
||||
useListener('connect-to-proxy', this.connect_to_proxy);
|
||||
useBus(this.env.posbus, 'start-cash-control', this.openCashControl);
|
||||
NumberBuffer.activate();
|
||||
|
||||
this.state = useState({
|
||||
uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING'
|
||||
debugWidgetIsShown: true,
|
||||
mobileSearchBarIsShown: false,
|
||||
hasBigScrollBars: false,
|
||||
sound: { src: null },
|
||||
notification: {
|
||||
isShown: false,
|
||||
message: '',
|
||||
duration: 2000,
|
||||
},
|
||||
loadingSkipButtonIsShown: 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;
|
||||
|
||||
const pos = reactive(this.env.pos, batched(() => this.render(true)))
|
||||
useSubEnv({ pos });
|
||||
|
||||
onMounted(() => {
|
||||
// remove default webclient handlers that induce click delay
|
||||
$(document).off();
|
||||
$(window).off();
|
||||
$('html').off();
|
||||
$('body').off();
|
||||
});
|
||||
|
||||
onWillDestroy(() => {
|
||||
this.env.pos.destroy();
|
||||
});
|
||||
|
||||
onError((error) => {
|
||||
// error is an OwlError object.
|
||||
console.error(error.cause);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this.props.setupIsDone(this);
|
||||
});
|
||||
}
|
||||
|
||||
// GETTERS //
|
||||
|
||||
get customerFacingDisplayButtonIsShown() {
|
||||
return this.env.pos.config.iface_customer_facing_display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to give the `state.mobileSearchBarIsShown` value to main screen props
|
||||
*/
|
||||
get mainScreenPropsFielded() {
|
||||
return Object.assign({}, this.mainScreenProps, {
|
||||
mobileSearchBarIsShown: this.state.mobileSearchBarIsShown,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
await this.env.pos.load_server_data();
|
||||
await this.setupBarcodeParser();
|
||||
if(this.env.pos.config.use_proxy){
|
||||
await this.connect_to_proxy();
|
||||
}
|
||||
// Load the saved `env.pos.toRefundLines` from localStorage when
|
||||
// the PosGlobalState is ready.
|
||||
Object.assign(this.env.pos.toRefundLines, this.env.pos.db.load('TO_REFUND_LINES') || {});
|
||||
this._buildChrome();
|
||||
this._closeOtherTabs();
|
||||
this.env.pos.selectedCategoryId = this.env.pos.config.start_category && this.env.pos.config.iface_start_categ_id
|
||||
? this.env.pos.config.iface_start_categ_id[0]
|
||||
: 0;
|
||||
this.state.uiState = 'READY';
|
||||
this._showStartScreen();
|
||||
setTimeout(() => this._runBackgroundTasks());
|
||||
} 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;
|
||||
if (error.cause) {
|
||||
body = error.cause.message;
|
||||
} else {
|
||||
body = error.stack;
|
||||
}
|
||||
}
|
||||
|
||||
await this.showPopup('ErrorTracebackPopup', {
|
||||
title,
|
||||
body,
|
||||
exitButtonIsShown: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_runBackgroundTasks() {
|
||||
// push order in the background, no need to await
|
||||
this.env.pos.push_orders();
|
||||
// Allow using the app even if not all the images are loaded.
|
||||
// Basically, preload the images in the background.
|
||||
this._preloadImages();
|
||||
if (this.env.pos.config.limited_partners_loading && this.env.pos.config.partner_load_background) {
|
||||
// Wrap in fresh reactive: none of the reads during loading should subscribe to anything
|
||||
reactive(this.env.pos).loadPartnersBackground();
|
||||
}
|
||||
if (this.env.pos.config.limited_products_loading && this.env.pos.config.product_load_background) {
|
||||
// Wrap in fresh reactive: none of the reads during loading should subscribe to anything
|
||||
reactive(this.env.pos).loadProductsBackground().then(() => {
|
||||
this.render(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setupBarcodeParser() {
|
||||
if (!this.env.pos.company.nomenclature_id) {
|
||||
const errorMessage = this.env._t("The barcode nomenclature setting is not configured. " +
|
||||
"Make sure to configure it on your Point of Sale configuration settings");
|
||||
throw new Error(this.env._t("Missing barcode nomenclature"), { cause: { message: errorMessage } });
|
||||
|
||||
}
|
||||
const barcode_parser = new BarcodeParser({ nomenclature_id: this.env.pos.company.nomenclature_id });
|
||||
this.env.barcode_reader.set_barcode_parser(barcode_parser);
|
||||
const fallbackNomenclature = this.env.pos.company.fallback_nomenclature_id;
|
||||
if (fallbackNomenclature) {
|
||||
const fallbackBarcodeParser = new BarcodeParser({ nomenclature_id: fallbackNomenclature });
|
||||
this.env.barcode_reader.setFallbackBarcodeParser(fallbackBarcodeParser);
|
||||
}
|
||||
return barcode_parser.is_loaded();
|
||||
}
|
||||
|
||||
connect_to_proxy() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.env.barcode_reader.disconnect_from_proxy();
|
||||
this.state.loadingSkipButtonIsShown = true;
|
||||
this.env.proxy.autoconnect({
|
||||
force_ip: this.env.pos.config.proxy_ip || undefined,
|
||||
progress: function(prog){},
|
||||
}).then(
|
||||
() => {
|
||||
if (this.env.pos.config.iface_scan_via_proxy) {
|
||||
this.env.barcode_reader.connect_to_proxy();
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
(statusText, url) => {
|
||||
// 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: this.env._t('HTTPS connection to IoT Box failed'),
|
||||
body: _.str.sprintf(
|
||||
this.env._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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
openCashControl() {
|
||||
if (this.shouldShowCashControl()) {
|
||||
this.showPopup('CashOpeningPopup');
|
||||
}
|
||||
}
|
||||
|
||||
shouldShowCashControl() {
|
||||
return this.env.pos.config.cash_control && this.env.pos.pos_session.state == 'opening_control';
|
||||
}
|
||||
|
||||
// EVENT HANDLERS //
|
||||
|
||||
_showStartScreen() {
|
||||
const { name, props } = this.startScreen;
|
||||
this.showScreen(name, props);
|
||||
}
|
||||
_getSavedScreen(order) {
|
||||
return order.get_screen_data();
|
||||
}
|
||||
__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 });
|
||||
this.env.pos.tempScreenIsShown = true;
|
||||
}
|
||||
__closeTempScreen() {
|
||||
this.tempScreen.isShown = false;
|
||||
this.env.pos.tempScreenIsShown = false;
|
||||
this.tempScreen.name = null;
|
||||
}
|
||||
__showScreen({ detail: { name, props = {} } }) {
|
||||
const component = this.constructor.components[name];
|
||||
// 1. Set the information of the screen to display.
|
||||
this.mainScreen.name = name;
|
||||
this.mainScreen.component = component;
|
||||
this.mainScreenProps = props;
|
||||
|
||||
// 2. Save the screen to the order.
|
||||
// - This screen is shown when the order is selected.
|
||||
if (!(component.prototype instanceof IndependentToOrderScreen) && name !== "ReprintReceiptScreen") {
|
||||
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({ 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 there are orders in the db left unsynced, we try to sync.
|
||||
await this.env.pos.push_orders_with_closing_popup();
|
||||
window.location = '/web#action=point_of_sale.action_client_pos_menu';
|
||||
}
|
||||
_toggleDebugWidget() {
|
||||
this.state.debugWidgetIsShown = !this.state.debugWidgetIsShown;
|
||||
}
|
||||
_toggleMobileSearchBar({ detail: isSearchBarEnabled }) {
|
||||
if (isSearchBarEnabled !== null) {
|
||||
this.state.mobileSearchBarIsShown = isSearchBarEnabled;
|
||||
} else {
|
||||
this.state.mobileSearchBarIsShown = !this.state.mobileSearchBarIsShown;
|
||||
}
|
||||
}
|
||||
_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.synch.status = status;
|
||||
this.env.pos.synch.pending = pending;
|
||||
}
|
||||
_onShowNotification({ detail: { message, duration } }) {
|
||||
this.state.notification.isShown = true;
|
||||
this.state.notification.message = message;
|
||||
this.state.notification.duration = duration;
|
||||
}
|
||||
_onCloseNotification() {
|
||||
this.state.notification.isShown = false;
|
||||
}
|
||||
/**
|
||||
* Save `env.pos.toRefundLines` in localStorage on beforeunload - closing the
|
||||
* browser, reloading or going to other page.
|
||||
*/
|
||||
_onBeforeUnload() {
|
||||
this.env.pos.db.save('TO_REFUND_LINES', this.env.pos.toRefundLines);
|
||||
}
|
||||
|
||||
get isTicketScreenShown() {
|
||||
return this.mainScreen.name === 'TicketScreen';
|
||||
}
|
||||
|
||||
// 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}&unique=${product.__last_update}`;
|
||||
}
|
||||
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}&unique=${category.write_date}`;
|
||||
}
|
||||
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.hasBigScrollBars = true;
|
||||
}
|
||||
|
||||
this._disableBackspaceBack();
|
||||
}
|
||||
// prevent backspace from performing a 'back' navigation
|
||||
_disableBackspaceBack() {
|
||||
$(document).on('keydown', function (e) {
|
||||
if (e.which === 8 && !$(e.target).is('input, textarea')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
_closeOtherTabs() {
|
||||
localStorage['message'] = '';
|
||||
localStorage['message'] = JSON.stringify({
|
||||
message: 'close_tabs',
|
||||
session: this.env.pos.pos_session.id,
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
'storage',
|
||||
(event) => {
|
||||
if (event.key === 'message' && event.newValue) {
|
||||
const msg = JSON.parse(event.newValue);
|
||||
if (
|
||||
msg.message === 'close_tabs' &&
|
||||
msg.session == this.env.pos.pos_session.id
|
||||
) {
|
||||
console.info(
|
||||
'POS / Session opened in another window. EXITING POS'
|
||||
);
|
||||
this._closePos();
|
||||
}
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
showCashMoveButton() {
|
||||
return this.env.pos && this.env.pos.config && this.env.pos.config.cash_control;
|
||||
}
|
||||
|
||||
// UNEXPECTED ERROR HANDLING //
|
||||
|
||||
/**
|
||||
* This method is used to handle unexpected errors. It is registered to
|
||||
* the `error_handlers` service when this component is properly mounted.
|
||||
* See `onMounted` hook of the `ChromeAdapter` component.
|
||||
* @param {*} env
|
||||
* @param {UncaughtClientError | UncaughtPromiseError} error
|
||||
* @param {*} originalError
|
||||
* @returns {boolean}
|
||||
*/
|
||||
errorHandler(env, error, originalError) {
|
||||
if (!env.pos) return false;
|
||||
const errorToHandle = identifyError(originalError);
|
||||
// Assume that the unhandled falsey rejections can be ignored.
|
||||
if (errorToHandle) {
|
||||
this._errorHandler(error, errorToHandle);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_errorHandler(error, errorToHandle) {
|
||||
if (errorToHandle instanceof RPCError) {
|
||||
const { message, data } = errorToHandle;
|
||||
if (odooExceptionTitleMap.has(errorToHandle.exceptionName)) {
|
||||
const title = odooExceptionTitleMap.get(errorToHandle.exceptionName).toString();
|
||||
this.showPopup('ErrorPopup', { title, body: data.message });
|
||||
} else {
|
||||
this.showPopup('ErrorTracebackPopup', {
|
||||
title: message,
|
||||
body: data.message + '\n' + data.debug + '\n',
|
||||
});
|
||||
}
|
||||
} else if (errorToHandle instanceof ConnectionLostError) {
|
||||
this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Connection is lost'),
|
||||
body: this.env._t('Check the internet connection then try again.'),
|
||||
});
|
||||
} else if (errorToHandle instanceof ConnectionAbortedError) {
|
||||
this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Connection is aborted'),
|
||||
body: this.env._t('Check the internet connection then try again.'),
|
||||
});
|
||||
} else if (errorToHandle instanceof Error) {
|
||||
// If `errorToHandle` is a normal Error (such as TypeError),
|
||||
// the annotated traceback can be found from `error`.
|
||||
this.showPopup('ErrorTracebackPopup', {
|
||||
// Hopefully the message is translated.
|
||||
title: `${errorToHandle.name}: ${errorToHandle.message}`,
|
||||
body: error.traceback,
|
||||
});
|
||||
} else {
|
||||
// Hey developer. It's your fault that the error reach here.
|
||||
// Please, throw an Error object in order to get stack trace of the error.
|
||||
// At least we can find the file that throws the error when you look
|
||||
// at the console.
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Unknown Error'),
|
||||
body: this.env._t('Unable to show information about this error.'),
|
||||
});
|
||||
console.error('Unknown error. Unable to show information about this error.', errorToHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Chrome.template = 'Chrome';
|
||||
Object.defineProperty(Chrome, "components", {
|
||||
get () {
|
||||
return Object.assign({ Transition }, PosComponent.components);
|
||||
}
|
||||
})
|
||||
|
||||
Registries.Component.add(Chrome);
|
||||
|
||||
return Chrome;
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
odoo.define('point_of_sale.CashMoveButton', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _t } = require('web.core');
|
||||
const { renderToString } = require('@web/core/utils/render');
|
||||
|
||||
const TRANSLATED_CASH_MOVE_TYPE = {
|
||||
in: _t('in'),
|
||||
out: _t('out'),
|
||||
};
|
||||
|
||||
class CashMoveButton extends PosComponent {
|
||||
async onClick() {
|
||||
const { confirmed, payload } = await this.showPopup('CashMovePopup');
|
||||
if (!confirmed) return;
|
||||
const { type, amount, reason } = payload;
|
||||
const translatedType = TRANSLATED_CASH_MOVE_TYPE[type];
|
||||
const formattedAmount = this.env.pos.format_currency(amount);
|
||||
if (!amount) {
|
||||
return this.showNotification(
|
||||
_.str.sprintf(this.env._t('Cash in/out of %s is ignored.'), formattedAmount),
|
||||
3000
|
||||
);
|
||||
}
|
||||
const extras = { formattedAmount, translatedType };
|
||||
await this.rpc({
|
||||
model: 'pos.session',
|
||||
method: 'try_cash_in_out',
|
||||
args: [[this.env.pos.pos_session.id], type, amount, reason, extras],
|
||||
});
|
||||
if (this.env.proxy.printer) {
|
||||
const renderedReceipt = renderToString('point_of_sale.CashMoveReceipt', {
|
||||
_receipt: this._getReceiptInfo({ ...payload, translatedType, formattedAmount }),
|
||||
});
|
||||
const printResult = await this.env.proxy.printer.print_receipt(renderedReceipt);
|
||||
if (!printResult.successful) {
|
||||
this.showPopup('ErrorPopup', { title: printResult.message.title, body: printResult.message.body });
|
||||
}
|
||||
}
|
||||
this.showNotification(
|
||||
_.str.sprintf(this.env._t('Successfully made a cash %s of %s.'), type, formattedAmount),
|
||||
3000
|
||||
);
|
||||
}
|
||||
_getReceiptInfo(payload) {
|
||||
const result = { ...payload };
|
||||
result.cashier = this.env.pos.get_cashier();
|
||||
result.company = this.env.pos.company;
|
||||
result.date = new Date().toLocaleString();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
CashMoveButton.template = 'point_of_sale.CashMoveButton';
|
||||
|
||||
Registries.Component.add(CashMoveButton);
|
||||
|
||||
return CashMoveButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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 { name } = this.env.pos.get_cashier();
|
||||
return name ? name : '';
|
||||
}
|
||||
get avatar() {
|
||||
const user_id = this.env.pos.get_cashier_user_id();
|
||||
const id = user_id ? user_id : -1;
|
||||
return `/web/image/res.users/${id}/avatar_128`;
|
||||
}
|
||||
}
|
||||
CashierName.template = 'CashierName';
|
||||
|
||||
Registries.Component.add(CashierName);
|
||||
|
||||
return CashierName;
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
odoo.define('point_of_sale.CustomerFacingDisplayButton', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useState } = owl;
|
||||
|
||||
class CustomerFacingDisplayButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.local = this.env.pos.config.iface_customer_facing_display_local && !this.env.pos.config.iface_customer_facing_display_via_proxy;
|
||||
this.state = useState({ status: this.local ? 'success' : 'failure' });
|
||||
this._start();
|
||||
}
|
||||
get message() {
|
||||
return {
|
||||
success: '',
|
||||
warning: this.env._t('Connected, Not Owned'),
|
||||
failure: this.env._t('Disconnected'),
|
||||
not_found: this.env._t('Customer Screen Unsupported. Please upgrade the IoT Box'),
|
||||
}[this.state.status];
|
||||
}
|
||||
onClick() {
|
||||
if (this.local) {
|
||||
return this.onClickLocal();
|
||||
} else {
|
||||
return this.onClickProxy();
|
||||
}
|
||||
}
|
||||
async onClickLocal() {
|
||||
this.env.pos.customer_display = window.open('', 'Customer Display', 'height=600,width=900');
|
||||
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
|
||||
var $renderedHtml = $('<div>').html(renderedHtml);
|
||||
$(this.env.pos.customer_display.document.body).html($renderedHtml.find('.pos-customer_facing_display'));
|
||||
$(this.env.pos.customer_display.document.head).html($renderedHtml.find('.resources').html());
|
||||
}
|
||||
async onClickProxy() {
|
||||
try {
|
||||
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
|
||||
let ownership = await this.env.proxy.take_ownership_over_customer_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.proxy.posbox_supports_display) {
|
||||
this.env.proxy.posbox_supports_display = true;
|
||||
this._start();
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof error == 'undefined') {
|
||||
this.state.status = 'failure';
|
||||
} else {
|
||||
this.state.status = 'not_found';
|
||||
}
|
||||
}
|
||||
}
|
||||
_start() {
|
||||
if (this.local) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
async function loop() {
|
||||
if (self.env.proxy.posbox_supports_display) {
|
||||
try {
|
||||
let ownership = await self.env.proxy.test_ownership_of_customer_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.proxy.posbox_supports_display = false;
|
||||
}
|
||||
setTimeout(loop, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
loop();
|
||||
}
|
||||
}
|
||||
CustomerFacingDisplayButton.template = 'CustomerFacingDisplayButton';
|
||||
|
||||
Registries.Component.add(CustomerFacingDisplayButton);
|
||||
|
||||
return CustomerFacingDisplayButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
odoo.define('point_of_sale.DebugWidget', function (require) {
|
||||
'use strict';
|
||||
|
||||
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');
|
||||
|
||||
const { onMounted, onWillUnmount, useRef, useState } = owl;
|
||||
|
||||
class DebugWidget extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
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.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)
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
NumberBuffer.on('buffer-update', this, this._onBufferUpdate);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
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.proxy.debug_set_weight(weightInKg);
|
||||
}
|
||||
}
|
||||
resetWeight() {
|
||||
this.state.weightInput = '';
|
||||
this.env.proxy.debug_reset_weight();
|
||||
}
|
||||
async barcodeScan() {
|
||||
await this.env.barcode_reader.scan(this.state.barcodeInput);
|
||||
}
|
||||
async barcodeScanEAN() {
|
||||
const ean = this.env.barcode_reader.barcode_parser.sanitize_ean(
|
||||
this.state.barcodeInput || '0'
|
||||
);
|
||||
this.state.barcodeInput = ean;
|
||||
await this.env.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.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,35 @@
|
|||
odoo.define('point_of_sale.HeaderButton', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
|
||||
// Previously HeaderButtonWidget
|
||||
// This is the close session button
|
||||
class HeaderButton extends PosComponent {
|
||||
async onClick() {
|
||||
try {
|
||||
const info = await this.env.pos.getClosePosInfo();
|
||||
this.showPopup('ClosePosPopup', { info: info, keepBehind: true });
|
||||
} catch (e) {
|
||||
if (isConnectionError(e)) {
|
||||
this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t('Please check your internet connection and try again.'),
|
||||
});
|
||||
} else {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Unknown Error'),
|
||||
body: this.env._t('An unknown error prevents us from getting closing information.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HeaderButton.template = 'HeaderButton';
|
||||
|
||||
Registries.Component.add(HeaderButton);
|
||||
|
||||
return HeaderButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
odoo.define('point_of_sale.ProxyStatus', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { onMounted, onWillUnmount, useState } = owl;
|
||||
|
||||
// Previously ProxyStatusWidget
|
||||
class ProxyStatus extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
const initialProxyStatus = this.env.proxy.get('status');
|
||||
this.state = useState({
|
||||
status: initialProxyStatus.status,
|
||||
msg: initialProxyStatus.msg,
|
||||
});
|
||||
this.statuses = ['connected', 'connecting', 'disconnected', 'warning'];
|
||||
this.index = 0;
|
||||
|
||||
onMounted(() => {
|
||||
this.env.proxy.on('change:status', this, this._onChangeStatus);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.env.proxy.off('change:status', this, this._onChangeStatus);
|
||||
});
|
||||
}
|
||||
_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,39 @@
|
|||
odoo.define('point_of_sale.SaleDetailsButton', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { renderToString } = require('@web/core/utils/render');
|
||||
|
||||
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 = renderToString(
|
||||
'SaleDetailsReport',
|
||||
Object.assign({}, saleDetails, {
|
||||
date: new Date().toLocaleString(),
|
||||
pos: this.env.pos,
|
||||
})
|
||||
);
|
||||
const printResult = await this.env.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,17 @@
|
|||
odoo.define('point_of_sale.SyncNotification', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class SyncNotification extends PosComponent {
|
||||
onClick() {
|
||||
this.env.pos.push_orders(null, { show_error: true });
|
||||
}
|
||||
}
|
||||
SyncNotification.template = 'SyncNotification';
|
||||
|
||||
Registries.Component.add(SyncNotification);
|
||||
|
||||
return SyncNotification;
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
odoo.define('point_of_sale.TicketButton', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class TicketButton extends PosComponent {
|
||||
onClick() {
|
||||
if (this.props.isTicketScreenShown) {
|
||||
this.env.posbus.trigger('ticket-button-clicked');
|
||||
} else {
|
||||
this.showScreen('TicketScreen');
|
||||
}
|
||||
}
|
||||
get count() {
|
||||
if (this.env.pos) {
|
||||
return this.env.pos.get_order_list().length;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
TicketButton.template = 'TicketButton';
|
||||
|
||||
Registries.Component.add(TicketButton);
|
||||
|
||||
return TicketButton;
|
||||
});
|
||||
|
|
@ -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, B1);
|
||||
* // |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,107 @@
|
|||
odoo.define('point_of_sale.ControlButtonsMixin', function (require) {
|
||||
'use strict';
|
||||
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
/**
|
||||
* Component that has this mixin allows the use of `addControlButton`.
|
||||
* All added control buttons that satisfies the condition can be accessed
|
||||
* thru the `controlButtons` field of the Component's instance. These
|
||||
* control buttons can then be rendered in the Component.
|
||||
* @param {Function} x superclass
|
||||
*/
|
||||
const ControlButtonsMixin = (x) => {
|
||||
const controlButtonsToPosition = [];
|
||||
const sortedControlButtons = [];
|
||||
|
||||
class Extended extends x {
|
||||
get controlButtons() {
|
||||
return sortedControlButtons
|
||||
.filter((cb) => {
|
||||
return cb.condition ? cb.condition.bind(this)() : true;
|
||||
})
|
||||
.map((cb) =>
|
||||
Object.assign({}, cb, { component: Registries.Component.get(cb.component) })
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Object} controlButton
|
||||
* @param {Function} controlButton.component
|
||||
* Base class 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
|
||||
*/
|
||||
Extended.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) {
|
||||
sortedControlButtons.push(controlButton);
|
||||
} else {
|
||||
controlButtonsToPosition.push(controlButton);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this static method to make the added control buttons in proper
|
||||
* order.
|
||||
* NOTE: This isn't necessarily a fast algorithm. I doubt that the number
|
||||
* of control buttons will exceed an order of hundreds, so for practical
|
||||
* purposes, it is enough.
|
||||
*/
|
||||
Extended.sortControlButtons = function () {
|
||||
function setControlButton(locator, index, cb) {
|
||||
if (locator == 'replace') {
|
||||
sortedControlButtons[index] = cb;
|
||||
} else if (locator == 'before') {
|
||||
sortedControlButtons.splice(index, 0, cb);
|
||||
} else if (locator == 'after') {
|
||||
sortedControlButtons.splice(index + 1, 0, cb);
|
||||
}
|
||||
}
|
||||
function locate(cb) {
|
||||
const [locator, reference] = cb.position;
|
||||
const index = sortedControlButtons.findIndex((cb) => cb.name == reference);
|
||||
return [locator, index, reference];
|
||||
}
|
||||
const cbMissingReference = [];
|
||||
// 1. First pass. If the reference control button isn't there, collect it for second pass.
|
||||
for (let cb of controlButtonsToPosition) {
|
||||
const [locator, index] = locate(cb);
|
||||
if (index == -1) {
|
||||
cbMissingReference.push(cb);
|
||||
continue;
|
||||
}
|
||||
setControlButton(locator, index, cb);
|
||||
}
|
||||
// 2. Second pass.
|
||||
// If during the first pass, 1 -> 2, 2 -> 3, 3 -> 4, 4 -> 5 and 5 is already
|
||||
// in the sorted control buttons, then 1, 2, 3 & 4 are put in `cbMissingReference`.
|
||||
// This only means 2 things about the objects in `cbMissingReference`:
|
||||
// i) They are referencing the cb after them
|
||||
// ii) They really have missing reference.
|
||||
// Thus, we have to iterate the cb with missing reference in reverse.
|
||||
for (let cb of cbMissingReference.reverse()) {
|
||||
const [locator, index, reference] = locate(cb);
|
||||
if (index == -1) {
|
||||
console.warn(`'${cb.name}' is not properly position because '${reference}' is not found. Is '${reference}' spelled correctly?`);
|
||||
sortedControlButtons.push(cb);
|
||||
} else {
|
||||
setControlButton(locator, index, cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Extended;
|
||||
};
|
||||
|
||||
return ControlButtonsMixin;
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
odoo.define('point_of_sale.Gui', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { status } = owl;
|
||||
|
||||
/**
|
||||
* This module bridges the data classes (such as those defined in
|
||||
* models.js) to the view (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 {component} component component having the ui methods.
|
||||
*/
|
||||
const configureGui = ({ component }) => {
|
||||
config.component = component;
|
||||
config.availableMethods = new Set([
|
||||
'showScreen',
|
||||
'showPopup',
|
||||
'showTempScreen',
|
||||
'playSound',
|
||||
'setSyncStatus',
|
||||
'showNotification',
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = status(component) === 'mounted';
|
||||
if (availableMethods.has(key) && isMounted) {
|
||||
return component[key].bind(component);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { configureGui, Gui };
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
odoo.define('point_of_sale.AbstractReceiptScreen', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { nextFrame } = require('point_of_sale.utils');
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useRef } = owl;
|
||||
|
||||
/**
|
||||
* This relies on the assumption that there is a reference to
|
||||
* `order-receipt` so it is important to declare a `t-ref` to
|
||||
* `order-receipt` in the template of the Component that extends
|
||||
* this abstract component.
|
||||
*/
|
||||
class AbstractReceiptScreen extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orderReceipt = useRef('order-receipt');
|
||||
}
|
||||
async _printReceipt() {
|
||||
if (this.env.proxy.printer) {
|
||||
const printResult = await this.env.proxy.printer.print_receipt(this.orderReceipt.el.innerHTML);
|
||||
if (printResult.successful) {
|
||||
return true;
|
||||
} else {
|
||||
await this.showPopup('ErrorPopup', {
|
||||
title: printResult.message.title,
|
||||
body: printResult.message.body,
|
||||
});
|
||||
const { confirmed } = await this.showPopup('ConfirmPopup', {
|
||||
title: printResult.message.title,
|
||||
body: this.env._t('Do you want to print using the web printer?'),
|
||||
});
|
||||
if (confirmed) {
|
||||
// We want to call the _printWeb when the popup is fully gone
|
||||
// from the screen which happens after the next animation frame.
|
||||
await nextFrame();
|
||||
return await this._printWeb();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return await this._printWeb();
|
||||
}
|
||||
}
|
||||
async _printWeb() {
|
||||
try {
|
||||
window.print();
|
||||
return true;
|
||||
} catch (_err) {
|
||||
await this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Printing is not supported on some browsers'),
|
||||
body: this.env._t(
|
||||
'Printing is not supported on some browsers due to no default printing protocol ' +
|
||||
'is available. It is possible to print your tickets by making use of an IoT Box.'
|
||||
),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registries.Component.add(AbstractReceiptScreen);
|
||||
|
||||
return AbstractReceiptScreen;
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
odoo.define('point_of_sale.CurrencyAmount', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class CurrencyAmount extends PosComponent {}
|
||||
CurrencyAmount.template = 'CurrencyAmount';
|
||||
|
||||
Registries.Component.add(CurrencyAmount);
|
||||
|
||||
return CurrencyAmount;
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
odoo.define('point_of_sale.Draggable', function(require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { onMounted, useExternalListener } = owl;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
setup() {
|
||||
super.setup();
|
||||
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);
|
||||
|
||||
onMounted(() => {
|
||||
this.limitArea = this.props.limitArea
|
||||
? document.querySelector(this.props.limitArea)
|
||||
: this.el.offsetParent;
|
||||
if (!this.limitArea) return;
|
||||
this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect();
|
||||
if (this.limitArea === this.el.offsetParent) {
|
||||
this.limitLeft = 0;
|
||||
this.limitTop = 0;
|
||||
this.limitRight = this.limitAreaBoundingRect.width;
|
||||
this.limitBottom = this.limitAreaBoundingRect.height;
|
||||
} else {
|
||||
this.limitLeft = -this.el.offsetParent.offsetLeft;
|
||||
this.limitTop = -this.el.offsetParent.offsetTop;
|
||||
this.limitRight =
|
||||
this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft;
|
||||
this.limitBottom =
|
||||
this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop;
|
||||
}
|
||||
this.limitAreaWidth = this.limitAreaBoundingRect.width;
|
||||
this.limitAreaHeight = this.limitAreaBoundingRect.height;
|
||||
|
||||
// absolutely position the element then remove the transform.
|
||||
const elBoundingRect = this.el.getBoundingClientRect();
|
||||
this.el.style.top = `${elBoundingRect.top}px`;
|
||||
this.el.style.left = `${elBoundingRect.left}px`;
|
||||
this.el.style.transform = 'none';
|
||||
});
|
||||
}
|
||||
startDrag(event) {
|
||||
let realEvent;
|
||||
if (event instanceof CustomEvent) {
|
||||
realEvent = event.detail;
|
||||
} else {
|
||||
realEvent = event;
|
||||
}
|
||||
const { x, y } = this._getEventLoc(realEvent);
|
||||
this.isDragging = true;
|
||||
this.dx = this.el.offsetLeft - x;
|
||||
this.dy = this.el.offsetTop - y;
|
||||
event.stopPropagation();
|
||||
}
|
||||
move(event) {
|
||||
if (this.isDragging) {
|
||||
const { x: pointerX, y: pointerY } = this._getEventLoc(event);
|
||||
const posLeft = this._getPosLeft(pointerX, this.dx);
|
||||
const posTop = this._getPosTop(pointerY, this.dy);
|
||||
this.el.style.left = `${posLeft}px`;
|
||||
this.el.style.top = `${posTop}px`;
|
||||
}
|
||||
}
|
||||
endDrag() {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
this.trigger('drag-end', {
|
||||
loc: { top: this.el.offsetTop, left: this.el.offsetLeft },
|
||||
});
|
||||
}
|
||||
}
|
||||
_getEventLoc(event) {
|
||||
let coordX, coordY;
|
||||
if (event.touches && event.touches[0]) {
|
||||
coordX = event.touches[0].clientX;
|
||||
coordY = event.touches[0].clientY;
|
||||
} else {
|
||||
coordX = event.clientX;
|
||||
coordY = event.clientY;
|
||||
}
|
||||
return {
|
||||
x: coordX,
|
||||
y: coordY,
|
||||
};
|
||||
}
|
||||
_getPosLeft(pointerX, dx) {
|
||||
const posLeft = pointerX + dx;
|
||||
if (posLeft < this.limitLeft) {
|
||||
return this.limitLeft;
|
||||
} else if (posLeft > this.limitRight - this.el.offsetWidth) {
|
||||
return this.limitRight - this.el.offsetWidth;
|
||||
}
|
||||
return posLeft;
|
||||
}
|
||||
_getPosTop(pointerY, dy) {
|
||||
const posTop = pointerY + dy;
|
||||
if (posTop < this.limitTop) {
|
||||
return this.limitTop;
|
||||
} else if (posTop > this.limitBottom - this.el.offsetHeight) {
|
||||
return this.limitBottom - this.el.offsetHeight;
|
||||
}
|
||||
return posTop;
|
||||
}
|
||||
}
|
||||
Draggable.template = 'Draggable';
|
||||
|
||||
Registries.Component.add(Draggable);
|
||||
|
||||
return Draggable;
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
odoo.define('point_of_sale.IndependentToOrderScreen', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
|
||||
class IndependentToOrderScreen extends PosComponent {
|
||||
close() {
|
||||
const order = this.env.pos.get_order();
|
||||
const { name: screenName } = order.get_screen_data();
|
||||
this.showScreen(screenName);
|
||||
}
|
||||
}
|
||||
|
||||
return IndependentToOrderScreen;
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
odoo.define('point_of_sale.MobileOrderWidget', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class MobileOrderWidget extends PosComponent {
|
||||
get order() {
|
||||
return this.env.pos.get_order();
|
||||
}
|
||||
get total() {
|
||||
const _total = this.order ? this.order.get_total_with_tax() : 0;
|
||||
return this.env.pos.format_currency(_total);
|
||||
}
|
||||
get items_number() {
|
||||
return this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
MobileOrderWidget.template = 'MobileOrderWidget';
|
||||
|
||||
Registries.Component.add(MobileOrderWidget);
|
||||
|
||||
return MobileOrderWidget;
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
odoo.define('point_of_sale.NotificationSound', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class NotificationSound extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('ended', () => (this.props.sound.src = null));
|
||||
}
|
||||
}
|
||||
NotificationSound.template = 'NotificationSound';
|
||||
|
||||
Registries.Component.add(NotificationSound);
|
||||
|
||||
return NotificationSound;
|
||||
});
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
odoo.define('point_of_sale.NumberBuffer', function(require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const { parse } = require('web.field_utils');
|
||||
const { barcodeService } = require('@barcodes/barcode_service');
|
||||
const { _t } = require('web.core');
|
||||
const { Gui } = require('point_of_sale.Gui');
|
||||
|
||||
const { EventBus, onMounted, onWillUnmount, useComponent, useExternalListener } = owl;
|
||||
const INPUT_KEYS = new Set(
|
||||
['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split(''))
|
||||
);
|
||||
const CONTROL_KEYS = new Set(['Enter', 'Esc']);
|
||||
const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]);
|
||||
const getDefaultConfig = () => ({
|
||||
decimalPoint: false,
|
||||
triggerAtEnter: false,
|
||||
triggerAtEsc: false,
|
||||
triggerAtInput: false,
|
||||
nonKeyboardInputEvent: false,
|
||||
useWithBarcode: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* This is a singleton.
|
||||
*
|
||||
* Only one component can `use` the buffer at a time.
|
||||
* This is done by keeping track of each component (and its
|
||||
* corresponding state and config) using a stack (bufferHolderStack).
|
||||
* The component on top of the stack is the one that currently
|
||||
* `holds` the buffer.
|
||||
*
|
||||
* When the current component is unmounted, the top of the stack
|
||||
* is popped and NumberBuffer is set up again for the new component
|
||||
* on top of the stack.
|
||||
*
|
||||
* Usage
|
||||
* =====
|
||||
* - Activate in the construction of root component. `NumberBuffer.activate()`
|
||||
* - Use the buffer in a child component by calling `NumberBuffer.use(<config>)`
|
||||
* in the constructor of the child component.
|
||||
* - The component that `uses` the buffer has access to the following instance
|
||||
* methods of the NumberBuffer:
|
||||
* - get()
|
||||
* - set(val)
|
||||
* - reset()
|
||||
* - getFloat()
|
||||
* - capture()
|
||||
*
|
||||
* Note
|
||||
* ====
|
||||
* - No need to instantiate as it is a singleton created before exporting in this module.
|
||||
*
|
||||
* Possible Improvements
|
||||
* =====================
|
||||
* - Relieve the buffer from responsibility of handling `Enter` and other control keys.
|
||||
* - Make the constants (ALLOWED_KEYS, etc.) more configurable.
|
||||
* - Write more integration tests. NumberPopup can be used as test component.
|
||||
*/
|
||||
class NumberBuffer extends EventBus {
|
||||
constructor() {
|
||||
super();
|
||||
this.isReset = false;
|
||||
this.bufferHolderStack = [];
|
||||
}
|
||||
/**
|
||||
* @returns {String} value of the buffer, e.g. '-95.79'
|
||||
*/
|
||||
get() {
|
||||
return this.state ? this.state.buffer : null;
|
||||
}
|
||||
/**
|
||||
* Takes a string that is convertible to float, and set it as
|
||||
* value of the buffer. e.g. val = '2.99';
|
||||
*
|
||||
* @param {String} val
|
||||
*/
|
||||
set(val) {
|
||||
this.state.buffer = !isNaN(parseFloat(val)) ? val : '';
|
||||
this.trigger('buffer-update', this.state.buffer);
|
||||
}
|
||||
/**
|
||||
* Resets the buffer to empty string.
|
||||
*/
|
||||
reset() {
|
||||
this.isReset = true;
|
||||
this.state.buffer = '';
|
||||
this.trigger('buffer-update', this.state.buffer);
|
||||
}
|
||||
/**
|
||||
* Calling this function, we immediately invoke the `handler` method
|
||||
* that handles the contents of the input events buffer (`eventsBuffer`).
|
||||
* This is helpful when we don't want to wait for the timeout that
|
||||
* is supposed to invoke the handler.
|
||||
*/
|
||||
capture() {
|
||||
if (this.handler) {
|
||||
clearTimeout(this._timeout);
|
||||
this.handler();
|
||||
delete this.handler;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @returns {number} float equivalent of the value of buffer
|
||||
*/
|
||||
getFloat() {
|
||||
return parse.float(this.get());
|
||||
}
|
||||
/**
|
||||
* Add keyup listener to window via the useExternalListener hook.
|
||||
* When the component calling this is unmounted, the listener is also
|
||||
* removed from window.
|
||||
*/
|
||||
activate() {
|
||||
this.defaultDecimalPoint = _t.database.parameters.decimal_point;
|
||||
useExternalListener(window, 'keyup', this._onKeyboardInput.bind(this));
|
||||
}
|
||||
/**
|
||||
* @param {Object} config Use to setup the buffer
|
||||
* @param {String|null} config.decimalPoint The decimal character.
|
||||
* @param {String|null} config.triggerAtEnter Event triggered when 'Enter' key is pressed.
|
||||
* @param {String|null} config.triggerAtEsc Event triggered when 'Esc' key is pressed.
|
||||
* @param {String|null} config.triggerAtInput Event triggered for every accepted input.
|
||||
* @param {String|null} config.nonKeyboardInputEvent Also listen to a non-keyboard input event
|
||||
* that carries a payload of { key }. The key is checked if it is a valid input. If valid,
|
||||
* the number buffer is modified just as it is modified when a keyboard key is pressed.
|
||||
* @param {Boolean} config.useWithBarcode Whether this buffer is used with barcode.
|
||||
* @emits config.triggerAtEnter when 'Enter' key is pressed.
|
||||
* @emits config.triggerAtEsc when 'Esc' key is pressed.
|
||||
* @emits config.triggerAtInput when an input is accepted.
|
||||
*/
|
||||
use(config) {
|
||||
this.eventsBuffer = [];
|
||||
const currentComponent = useComponent();
|
||||
config = Object.assign(getDefaultConfig(), config);
|
||||
onMounted(() => {
|
||||
this.bufferHolderStack.push({
|
||||
component: currentComponent,
|
||||
state: config.state ? config.state : { buffer: '', toStartOver: false },
|
||||
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
|
||||
? barcodeService.maxTimeBetweenKeysInMs
|
||||
: 0;
|
||||
}
|
||||
_onKeyboardInput(event) {
|
||||
return this._bufferEvents(this._onInput(event => event.key))(event);
|
||||
}
|
||||
_onNonKeyboardInput(event) {
|
||||
return this._bufferEvents(this._onInput(event => event.detail.key))(event);
|
||||
}
|
||||
_bufferEvents(handler) {
|
||||
return event => {
|
||||
if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return;
|
||||
clearTimeout(this._timeout);
|
||||
this.eventsBuffer.push(event);
|
||||
this._timeout = setTimeout(handler, this.maxTimeBetweenKeys);
|
||||
this.handler = handler
|
||||
};
|
||||
}
|
||||
_onInput(keyAccessor) {
|
||||
return () => {
|
||||
if (this.eventsBuffer.length <= 2) {
|
||||
// Check first the buffer if its contents are all valid
|
||||
// number input.
|
||||
for (let event of this.eventsBuffer) {
|
||||
if (!ALLOWED_KEYS.has(keyAccessor(event))) {
|
||||
this.eventsBuffer = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
// At this point, all the events in buffer
|
||||
// contains number input. It's now okay to handle
|
||||
// each input.
|
||||
for (let event of this.eventsBuffer) {
|
||||
this._handleInput(keyAccessor(event));
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
this.eventsBuffer = [];
|
||||
};
|
||||
}
|
||||
_handleInput(key) {
|
||||
if (key === 'Enter' && this.config.triggerAtEnter) {
|
||||
this.component.trigger(this.config.triggerAtEnter, this.state);
|
||||
} else if (key === 'Esc' && this.config.triggerAtEsc) {
|
||||
this.component.trigger(this.config.triggerAtEsc, this.state);
|
||||
} else if (INPUT_KEYS.has(key)) {
|
||||
this._updateBuffer(key);
|
||||
if (this.config.triggerAtInput)
|
||||
this.component.trigger(this.config.triggerAtInput, { buffer: this.state.buffer, key });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates the current buffer state using the given input.
|
||||
* @param {String} input valid input
|
||||
*/
|
||||
_updateBuffer(input) {
|
||||
const isEmpty = val => {
|
||||
return val === '' || val === null;
|
||||
};
|
||||
if (input === undefined || input === null) return;
|
||||
let isFirstInput = isEmpty(this.state.buffer);
|
||||
if (input === ',' || input === '.') {
|
||||
if (this.state.toStartOver) {
|
||||
this.state.buffer = '';
|
||||
}
|
||||
if (isFirstInput) {
|
||||
this.state.buffer = '0' + this.decimalPoint;
|
||||
} else if (!this.state.buffer.length || this.state.buffer === '-') {
|
||||
this.state.buffer += '0' + this.decimalPoint;
|
||||
} else if (this.state.buffer.indexOf(this.decimalPoint) < 0) {
|
||||
this.state.buffer = this.state.buffer + this.decimalPoint;
|
||||
}
|
||||
} else if (input === 'Delete') {
|
||||
if (this.isReset) {
|
||||
this.state.buffer = '';
|
||||
this.isReset = false;
|
||||
return;
|
||||
}
|
||||
this.state.buffer = isEmpty(this.state.buffer) ? null : '';
|
||||
} else if (input === 'Backspace') {
|
||||
if (this.isReset) {
|
||||
this.state.buffer = '';
|
||||
this.isReset = false;
|
||||
return;
|
||||
}
|
||||
if (this.state.toStartOver) {
|
||||
this.state.buffer = '';
|
||||
}
|
||||
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 (this.state.toStartOver) { // when we want to erase the current buffer for a new value
|
||||
this.state.buffer = '';
|
||||
}
|
||||
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;
|
||||
// it should not be in a start the buffer over state anymore.
|
||||
this.state.toStartOver = false;
|
||||
|
||||
if (this.config.maxValue && this.state.buffer > this.config.maxValue) {
|
||||
this.state.buffer = this.config.maxValue.toString();
|
||||
this.config.maxValueReached();
|
||||
}
|
||||
|
||||
this.trigger('buffer-update', this.state.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
return new NumberBuffer();
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
odoo.define('point_of_sale.SearchBar', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { useAutofocus, useListener } = require("@web/core/utils/hooks");
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useExternalListener, useState } = owl;
|
||||
|
||||
/**
|
||||
* This is a simple configurable search bar component. It has search fields
|
||||
* and selection filter. Search fields allow the users to specify the type
|
||||
* of their searches. The filter is a dropdown menu for selection. Depending on
|
||||
* user's action, this component emits corresponding event with the action
|
||||
* information (payload).
|
||||
*
|
||||
* TODO: This component can be made more generic and be able to replace
|
||||
* all the search bars across pos ui.
|
||||
*
|
||||
* @prop {{
|
||||
* config: {
|
||||
* searchFields: Map<string, string>,
|
||||
* filter: { show: boolean, options: Map<string, { text: string, indented: boolean? }> }
|
||||
* },
|
||||
* placeholder: string,
|
||||
* }}
|
||||
* @emits search @payload { fieldName: string, searchTerm: '' }
|
||||
* @emits filter-selected @payload { filter: string }
|
||||
*
|
||||
* NOTE: The payload of the emitted event is accessible via the `detail`
|
||||
* field of the event.
|
||||
*/
|
||||
class SearchBar extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useAutofocus();
|
||||
useExternalListener(window, 'click', this._hideOptions);
|
||||
useListener('click-search-field', this._onClickSearchField);
|
||||
useListener('select-filter', this._onSelectFilter);
|
||||
this.filterOptionsList = [...this.props.config.filter.options.keys()];
|
||||
this.searchFieldsList = [...this.props.config.searchFields.keys()];
|
||||
const defaultSearchFieldId = this.searchFieldsList.indexOf(
|
||||
this.props.config.defaultSearchDetails.fieldName
|
||||
);
|
||||
this.state = useState({
|
||||
searchInput: this.props.config.defaultSearchDetails.searchTerm || '',
|
||||
selectedSearchFieldId: defaultSearchFieldId == -1 ? 0 : defaultSearchFieldId,
|
||||
showSearchFields: false,
|
||||
showFilterOptions: false,
|
||||
selectedFilter: this.props.config.defaultFilter || this.filterOptionsList[0],
|
||||
});
|
||||
}
|
||||
_onSelectFilter({ detail: key }) {
|
||||
this.state.selectedFilter = key;
|
||||
this.trigger('filter-selected', { filter: this.state.selectedFilter });
|
||||
}
|
||||
/**
|
||||
* When pressing vertical arrow keys, do not move the input cursor.
|
||||
*/
|
||||
onSearchInputKeydown(event) {
|
||||
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* When vertical arrow keys are pressed, select fields for searching.
|
||||
* When enter key is pressed, trigger search event if there is searchInput.
|
||||
*/
|
||||
onSearchInputKeyup(event) {
|
||||
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
|
||||
this.state.selectedSearchFieldId = this._fieldIdToSelect(event.key);
|
||||
} else if (event.key === 'Enter' || this.state.searchInput == '') {
|
||||
this._onClickSearchField({ detail: this.searchFieldsList[this.state.selectedSearchFieldId] });
|
||||
} else {
|
||||
if (this.state.selectedSearchFieldId === -1 && this.searchFieldsList.length) {
|
||||
this.state.selectedSearchFieldId = 0;
|
||||
}
|
||||
this.state.showSearchFields = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called when a search field is clicked.
|
||||
*/
|
||||
_onClickSearchField({ detail: fieldName }) {
|
||||
this.state.showSearchFields = false;
|
||||
this.trigger('search', { fieldName, searchTerm: this.state.searchInput });
|
||||
}
|
||||
/**
|
||||
* Given an arrow key, return the next selectedSearchFieldId.
|
||||
* E.g. If the selectedSearchFieldId is 1 and ArrowDown is pressed, return 2.
|
||||
*
|
||||
* @param {string} key vertical arrow key
|
||||
*/
|
||||
_fieldIdToSelect(key) {
|
||||
const length = this.searchFieldsList.length;
|
||||
if (!length) return null;
|
||||
if (this.state.selectedSearchFieldId === -1) return 0;
|
||||
const current = this.state.selectedSearchFieldId || length;
|
||||
return (current + (key === 'ArrowDown' ? 1 : -1)) % length;
|
||||
}
|
||||
_hideOptions() {
|
||||
this.state.showFilterOptions = false;
|
||||
this.state.showSearchFields = false;
|
||||
}
|
||||
}
|
||||
SearchBar.template = 'point_of_sale.SearchBar';
|
||||
Registries.Component.add(SearchBar);
|
||||
|
||||
return SearchBar;
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
odoo.define('point_of_sale.Notification', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { onMounted } = owl;
|
||||
|
||||
class Notification extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this.closeNotification);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
this.closeNotification();
|
||||
}, this.props.duration)
|
||||
});
|
||||
}
|
||||
}
|
||||
Notification.template = 'Notification';
|
||||
|
||||
Registries.Component.add(Notification);
|
||||
|
||||
return Notification;
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const { useBus } = require('@web/core/utils/hooks');
|
||||
|
||||
/**
|
||||
* Implement this abstract class by extending it like so:
|
||||
* ```js
|
||||
* class ConcretePopup extends AbstractAwaitablePopup {
|
||||
* async getPayload() {
|
||||
* return 'result';
|
||||
* }
|
||||
* }
|
||||
* ConcretePopup.template = 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 {
|
||||
setup() {
|
||||
super.setup();
|
||||
if (this.props.confirmKey) {
|
||||
useBus(this.env.posbus, `confirm-popup-${this.props.id}`, this.confirm);
|
||||
}
|
||||
if (this.props.cancelKey) {
|
||||
useBus(this.env.posbus, `cancel-popup-${this.props.id}`, this.cancel);
|
||||
}
|
||||
}
|
||||
async confirm() {
|
||||
this.env.posbus.trigger('close-popup', {
|
||||
popupId: this.props.id,
|
||||
response: { confirmed: true, payload: await this.getPayload() },
|
||||
});
|
||||
}
|
||||
cancel() {
|
||||
this.env.posbus.trigger('close-popup', {
|
||||
popupId: this.props.id,
|
||||
response: { confirmed: false, payload: null },
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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,97 @@
|
|||
odoo.define('point_of_sale.CashMovePopup', function (require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
const { parse } = require('web.field_utils');
|
||||
const { useValidateCashInput, useAsyncLockedMethod } = require('point_of_sale.custom_hooks');
|
||||
|
||||
const { useRef, useState } = owl;
|
||||
|
||||
class CashMovePopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({
|
||||
inputType: '', // '' | 'in' | 'out'
|
||||
inputAmount: '',
|
||||
inputReason: '',
|
||||
inputHasError: false,
|
||||
parsedAmount: 0,
|
||||
});
|
||||
this.inputAmountRef = useRef('input-amount-ref');
|
||||
useValidateCashInput('input-amount-ref');
|
||||
this.confirm = useAsyncLockedMethod(this.confirm);
|
||||
}
|
||||
confirm() {
|
||||
try {
|
||||
parse.float(this.state.inputAmount);
|
||||
} catch (_error) {
|
||||
this.state.inputHasError = true;
|
||||
this.errorMessage = this.env._t('Invalid amount');
|
||||
return;
|
||||
}
|
||||
if (this.state.inputType == '') {
|
||||
this.state.inputHasError = true;
|
||||
this.errorMessage = this.env._t('Select either Cash In or Cash Out before confirming.');
|
||||
return;
|
||||
}
|
||||
if (this.state.inputType === 'out' && this.state.inputAmount > 0) {
|
||||
this.state.inputHasError = true;
|
||||
this.errorMessage = this.env._t('Insert a negative amount with the Cash Out option.');
|
||||
return;
|
||||
}
|
||||
if (this.state.inputType === 'in' && this.state.inputAmount < 0) {
|
||||
this.state.inputHasError = true;
|
||||
this.errorMessage = this.env._t('Insert a positive amount with the Cash In option.');
|
||||
return;
|
||||
}
|
||||
if (parse.float(this.state.inputAmount) < 0) {
|
||||
this.state.inputAmount = this.state.inputAmount.substring(1);
|
||||
}
|
||||
return super.confirm();
|
||||
}
|
||||
_onAmountKeypress(event) {
|
||||
if (event.key === '-') {
|
||||
event.preventDefault();
|
||||
this.state.inputAmount = this.state.inputType === 'out' ? this.state.inputAmount.substring(1) : `-${this.state.inputAmount}`;
|
||||
this.state.inputType = this.state.inputType === 'out' ? 'in' : 'out';
|
||||
this.handleInputChange();
|
||||
}
|
||||
}
|
||||
onClickButton(type) {
|
||||
let amount = this.state.inputAmount;
|
||||
if (type === 'in') {
|
||||
this.state.inputAmount = amount.charAt(0) === '-' ? amount.substring(1) : amount;
|
||||
} else {
|
||||
this.state.inputAmount = amount.charAt(0) === '-' ? amount : `-${amount}`;
|
||||
}
|
||||
this.state.inputType = type;
|
||||
this.state.inputHasError = false;
|
||||
this.inputAmountRef.el && this.inputAmountRef.el.focus();
|
||||
if (amount && amount !== '-') {
|
||||
this.handleInputChange();
|
||||
}
|
||||
}
|
||||
getPayload() {
|
||||
return {
|
||||
amount: parse.float(this.state.inputAmount),
|
||||
reason: this.state.inputReason.trim(),
|
||||
type: this.state.inputType,
|
||||
};
|
||||
}
|
||||
handleInputChange() {
|
||||
if (this.inputAmountRef.el.classList.contains('invalid-cash-input')) return;
|
||||
this.state.parsedAmount = parse.float(this.state.inputAmount);
|
||||
}
|
||||
}
|
||||
CashMovePopup.template = 'point_of_sale.CashMovePopup';
|
||||
CashMovePopup.defaultProps = {
|
||||
cancelText: _lt('Cancel'),
|
||||
title: _lt('Cash In/Out'),
|
||||
};
|
||||
|
||||
Registries.Component.add(CashMovePopup);
|
||||
|
||||
return CashMovePopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
odoo.define('point_of_sale.CashOpeningPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const { useValidateCashInput } = require('point_of_sale.custom_hooks');
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { parse } = require('web.field_utils');
|
||||
|
||||
const { useState, useRef } = owl;
|
||||
|
||||
class CashOpeningPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.manualInputCashCount = null;
|
||||
this.state = useState({
|
||||
notes: "",
|
||||
openingCash: this.env.pos.pos_session.cash_register_balance_start || 0,
|
||||
displayMoneyDetailsPopup: false,
|
||||
});
|
||||
useValidateCashInput("openingCashInput", this.env.pos.pos_session.cash_register_balance_start);
|
||||
this.openingCashInputRef = useRef('openingCashInput');
|
||||
}
|
||||
//@override
|
||||
async confirm() {
|
||||
this.env.pos.pos_session.cash_register_balance_start = this.state.openingCash;
|
||||
this.env.pos.pos_session.state = 'opened';
|
||||
this.rpc({
|
||||
model: 'pos.session',
|
||||
method: 'set_cashbox_pos',
|
||||
args: [this.env.pos.pos_session.id, this.state.openingCash, this.state.notes],
|
||||
});
|
||||
super.confirm();
|
||||
}
|
||||
openDetailsPopup() {
|
||||
this.state.openingCash = 0;
|
||||
this.state.notes = "";
|
||||
this.state.displayMoneyDetailsPopup = true;
|
||||
}
|
||||
closeDetailsPopup() {
|
||||
this.state.displayMoneyDetailsPopup = false;
|
||||
}
|
||||
updateCashOpening({ total, moneyDetailsNotes }) {
|
||||
this.openingCashInputRef.el.value = this.env.pos.format_currency_no_symbol(total);
|
||||
this.state.openingCash = total;
|
||||
if (moneyDetailsNotes) {
|
||||
this.state.notes = moneyDetailsNotes;
|
||||
}
|
||||
this.manualInputCashCount = false;
|
||||
this.closeDetailsPopup();
|
||||
}
|
||||
handleInputChange(event) {
|
||||
if (event.target.classList.contains('invalid-cash-input')) return;
|
||||
this.manualInputCashCount = true;
|
||||
this.state.openingCash = parse.float(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
CashOpeningPopup.template = 'CashOpeningPopup';
|
||||
CashOpeningPopup.defaultProps = { cancelKey: false };
|
||||
Registries.Component.add(CashOpeningPopup);
|
||||
|
||||
return CashOpeningPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
odoo.define('point_of_sale.ClosePosPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { identifyError } = require('point_of_sale.utils');
|
||||
const { ConnectionLostError, ConnectionAbortedError} = require('@web/core/network/rpc_service')
|
||||
const { useState, useRef } = owl;
|
||||
const { useValidateCashInput } = require('point_of_sale.custom_hooks');
|
||||
const { parse } = require('web.field_utils');
|
||||
|
||||
class ClosePosPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.manualInputCashCount = false;
|
||||
this.cashControl = this.env.pos.config.cash_control;
|
||||
this.closingCashInputRef = useRef('closingCashInput');
|
||||
this.closeSessionClicked = false;
|
||||
this.moneyDetails = null;
|
||||
Object.assign(this, this.props.info);
|
||||
this.state = useState({
|
||||
displayMoneyDetailsPopup: false,
|
||||
});
|
||||
Object.assign(this.state, this.props.info.state);
|
||||
useValidateCashInput("closingCashInput");
|
||||
if (this.otherPaymentMethods && this.otherPaymentMethods.length > 0) {
|
||||
this.otherPaymentMethods.forEach(pm => {
|
||||
if (this._getShowDiff(pm)) {
|
||||
useValidateCashInput("closingCashInput_" + pm.id, this.state.payments[pm.id].counted);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
//@override
|
||||
async confirm() {
|
||||
if (!this.cashControl || !this.hasDifference()) {
|
||||
this.closeSession();
|
||||
} else if (this.hasUserAuthority()) {
|
||||
const { confirmed } = await this.showPopup('ConfirmPopup', {
|
||||
title: this.env._t('Payments Difference'),
|
||||
body: this.env._t('Do you want to accept payments difference and post a profit/loss journal entry?'),
|
||||
});
|
||||
if (confirmed) {
|
||||
this.closeSession();
|
||||
}
|
||||
} else {
|
||||
await this.showPopup('ConfirmPopup', {
|
||||
title: this.env._t('Payments Difference'),
|
||||
body: _.str.sprintf(
|
||||
this.env._t('The maximum difference allowed is %s.\n' +
|
||||
'Please contact your manager to accept the closing difference.'),
|
||||
this.env.pos.format_currency(this.amountAuthorizedDiff)
|
||||
),
|
||||
confirmText: this.env._t('OK'),
|
||||
})
|
||||
}
|
||||
}
|
||||
//@override
|
||||
async cancel() {
|
||||
if (this.canCancel()) {
|
||||
super.cancel();
|
||||
}
|
||||
}
|
||||
openDetailsPopup() {
|
||||
this.state.payments[this.defaultCashDetails.id].counted = 0;
|
||||
this.state.payments[this.defaultCashDetails.id].difference = -this.defaultCashDetails.amount;
|
||||
this.state.notes = "";
|
||||
this.state.displayMoneyDetailsPopup = true;
|
||||
}
|
||||
closeDetailsPopup() {
|
||||
this.state.displayMoneyDetailsPopup = false;
|
||||
}
|
||||
async downloadSalesReport() {
|
||||
await this.env.legacyActionManager.do_action('point_of_sale.sale_details_report', {
|
||||
additional_context: {
|
||||
active_ids: [this.env.pos.pos_session.id],
|
||||
},
|
||||
});
|
||||
}
|
||||
handleInputChange(paymentId, event) {
|
||||
if (event.target.classList.contains('invalid-cash-input')) return;
|
||||
let expectedAmount;
|
||||
if (this.defaultCashDetails && paymentId === this.defaultCashDetails.id) {
|
||||
this.manualInputCashCount = true;
|
||||
this.state.notes = '';
|
||||
expectedAmount = this.defaultCashDetails.amount;
|
||||
} else {
|
||||
expectedAmount = this.otherPaymentMethods.find(pm => paymentId === pm.id).amount;
|
||||
}
|
||||
this.state.payments[paymentId].counted = parse.float(event.target.value);
|
||||
this.state.payments[paymentId].difference =
|
||||
this.env.pos.round_decimals_currency(this.state.payments[paymentId].counted - expectedAmount);
|
||||
}
|
||||
updateCountedCash({ total, moneyDetailsNotes, moneyDetails }) {
|
||||
this.closingCashInputRef.el.value = this.env.pos.format_currency_no_symbol(total);
|
||||
this.state.payments[this.defaultCashDetails.id].counted = total;
|
||||
this.state.payments[this.defaultCashDetails.id].difference =
|
||||
this.env.pos.round_decimals_currency(this.state.payments[[this.defaultCashDetails.id]].counted - this.defaultCashDetails.amount);
|
||||
if (moneyDetailsNotes) {
|
||||
this.state.notes = moneyDetailsNotes;
|
||||
}
|
||||
this.manualInputCashCount = false;
|
||||
this.moneyDetails = moneyDetails;
|
||||
this.closeDetailsPopup();
|
||||
}
|
||||
hasDifference() {
|
||||
return Object.entries(this.state.payments).find(pm => pm[1].difference != 0);
|
||||
}
|
||||
hasUserAuthority() {
|
||||
const absDifferences = Object.entries(this.state.payments).map(pm => Math.abs(pm[1].difference));
|
||||
return this.isManager || this.amountAuthorizedDiff == null || Math.max(...absDifferences) <= this.amountAuthorizedDiff;
|
||||
}
|
||||
canCancel() {
|
||||
return true;
|
||||
}
|
||||
closePos() {
|
||||
this.trigger('close-pos');
|
||||
}
|
||||
async closeSession() {
|
||||
if (!this.closeSessionClicked) {
|
||||
this.closeSessionClicked = true;
|
||||
let response;
|
||||
// If there are orders in the db left unsynced, we try to sync.
|
||||
await this.env.pos.push_orders_with_closing_popup();
|
||||
if (this.cashControl) {
|
||||
response = await this.rpc({
|
||||
model: 'pos.session',
|
||||
method: 'post_closing_cash_details',
|
||||
args: [this.env.pos.pos_session.id],
|
||||
kwargs: {
|
||||
counted_cash: this.state.payments[this.defaultCashDetails.id].counted,
|
||||
}
|
||||
})
|
||||
if (!response.successful) {
|
||||
return this.handleClosingError(response);
|
||||
}
|
||||
}
|
||||
await this.rpc({
|
||||
model: 'pos.session',
|
||||
method: 'update_closing_control_state_session',
|
||||
args: [this.env.pos.pos_session.id, this.state.notes]
|
||||
})
|
||||
try {
|
||||
const bankPaymentMethodDiffPairs = this.otherPaymentMethods
|
||||
.filter((pm) => pm.type == 'bank')
|
||||
.map((pm) => [pm.id, this.state.payments[pm.id].difference]);
|
||||
response = await this.rpc({
|
||||
model: 'pos.session',
|
||||
method: 'close_session_from_ui',
|
||||
args: [this.env.pos.pos_session.id, bankPaymentMethodDiffPairs],
|
||||
context: this.env.session.user_context,
|
||||
});
|
||||
if (!response.successful) {
|
||||
return this.handleClosingError(response);
|
||||
}
|
||||
window.location = '/web#action=point_of_sale.action_client_pos_menu';
|
||||
} catch (error) {
|
||||
const iError = identifyError(error);
|
||||
if (iError instanceof ConnectionLostError || iError instanceof ConnectionAbortedError) {
|
||||
await this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t('Cannot close the session when offline.'),
|
||||
});
|
||||
} else {
|
||||
await this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Closing session error'),
|
||||
body: this.env._t(
|
||||
'An error has occurred when trying to close the session.\n' +
|
||||
'You will be redirected to the back-end to manually close the session.')
|
||||
})
|
||||
window.location = '/web#action=point_of_sale.action_client_pos_menu';
|
||||
}
|
||||
}
|
||||
this.closeSessionClicked = false;
|
||||
}
|
||||
}
|
||||
async handleClosingError(response) {
|
||||
await this.showPopup('ErrorPopup', {title: 'Error', body: response.message});
|
||||
if (response.redirect) {
|
||||
window.location = '/web#action=point_of_sale.action_client_pos_menu';
|
||||
}
|
||||
}
|
||||
_getShowDiff(pm) {
|
||||
return pm.type == 'bank' && pm.number !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
ClosePosPopup.template = 'ClosePosPopup';
|
||||
Registries.Component.add(ClosePosPopup);
|
||||
|
||||
return ClosePosPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
odoo.define('point_of_sale.ConfirmPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
// formerly ConfirmPopupWidget
|
||||
class ConfirmPopup extends AbstractAwaitablePopup {}
|
||||
ConfirmPopup.template = 'ConfirmPopup';
|
||||
ConfirmPopup.defaultProps = {
|
||||
confirmText: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
title: _lt('Confirm ?'),
|
||||
body: '',
|
||||
};
|
||||
|
||||
Registries.Component.add(ConfirmPopup);
|
||||
|
||||
return ConfirmPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
odoo.define('point_of_sale.ControlButtonPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
class ControlButtonPopup extends AbstractAwaitablePopup {
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {string} props.startingValue
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.controlButtons = this.props.controlButtons;
|
||||
}
|
||||
}
|
||||
ControlButtonPopup.template = 'ControlButtonPopup';
|
||||
ControlButtonPopup.defaultProps = {
|
||||
cancelText: _lt('Back'),
|
||||
controlButtons: [],
|
||||
confirmKey: false,
|
||||
};
|
||||
|
||||
Registries.Component.add(ControlButtonPopup);
|
||||
|
||||
return ControlButtonPopup;
|
||||
});
|
||||
|
|
@ -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,107 @@
|
|||
odoo.define('point_of_sale.EditListPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { useAutoFocusToLast } = require('point_of_sale.custom_hooks');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
const { useState } = owl;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
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: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
array: [],
|
||||
isSingleItem: false,
|
||||
};
|
||||
|
||||
Registries.Component.add(EditListPopup);
|
||||
|
||||
return EditListPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
odoo.define('point_of_sale.ErrorBarcodePopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const ErrorPopup = require('point_of_sale.ErrorPopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
// formerly ErrorBarcodePopupWidget
|
||||
class ErrorBarcodePopup extends ErrorPopup {
|
||||
get translatedMessage() {
|
||||
return this.env._t(this.props.message);
|
||||
}
|
||||
}
|
||||
ErrorBarcodePopup.template = 'ErrorBarcodePopup';
|
||||
ErrorBarcodePopup.defaultProps = {
|
||||
confirmText: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
title: _lt('Error'),
|
||||
body: '',
|
||||
message:
|
||||
_lt('The Point of Sale could not find any product, customer, employee or action associated with the scanned barcode.'),
|
||||
};
|
||||
|
||||
Registries.Component.add(ErrorBarcodePopup);
|
||||
|
||||
return ErrorBarcodePopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
odoo.define('point_of_sale.ErrorPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
// formerly ErrorPopupWidget
|
||||
class ErrorPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
owl.onMounted(this.onMounted);
|
||||
}
|
||||
onMounted() {
|
||||
this.playSound('error');
|
||||
}
|
||||
}
|
||||
ErrorPopup.template = 'ErrorPopup';
|
||||
ErrorPopup.defaultProps = {
|
||||
confirmText: _lt('Ok'),
|
||||
title: _lt('Error'),
|
||||
body: '',
|
||||
cancelKey: false,
|
||||
};
|
||||
|
||||
Registries.Component.add(ErrorPopup);
|
||||
|
||||
return ErrorPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
odoo.define('point_of_sale.ErrorTracebackPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const ErrorPopup = require('point_of_sale.ErrorPopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
// 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: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
confirmKey: false,
|
||||
title: _lt('Error with Traceback'),
|
||||
body: '',
|
||||
exitButtonIsShown: false,
|
||||
exitButtonText: _lt('Exit Pos'),
|
||||
exitButtonTrigger: 'close-pos'
|
||||
};
|
||||
|
||||
Registries.Component.add(ErrorTracebackPopup);
|
||||
|
||||
return ErrorTracebackPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
odoo.define('point_of_sale.MoneyDetailsPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useState } = owl;
|
||||
|
||||
/**
|
||||
* Even if this component has a "confirm and cancel"-like buttons, this should not be an AbstractAwaitablePopup.
|
||||
* We currently cannot show two popups at the same time, what we do is mount this component with its parent
|
||||
* and hide it with some css. The confirm button will just trigger an event to the parent.
|
||||
*/
|
||||
class MoneyDetailsPopup extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.currency = this.env.pos.currency;
|
||||
this.state = useState({
|
||||
moneyDetails: Object.fromEntries(this.env.pos.bills.map(bill => ([bill.value, 0]))),
|
||||
total: 0,
|
||||
});
|
||||
if (this.props.manualInputCashCount) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
get firstHalfMoneyDetails() {
|
||||
const moneyDetailsKeys = Object.keys(this.state.moneyDetails).sort((a, b) => a - b);
|
||||
return moneyDetailsKeys.slice(0, Math.ceil(moneyDetailsKeys.length/2));
|
||||
}
|
||||
get lastHalfMoneyDetails() {
|
||||
const moneyDetailsKeys = Object.keys(this.state.moneyDetails).sort((a, b) => a - b);
|
||||
return moneyDetailsKeys.slice(Math.ceil(moneyDetailsKeys.length/2), moneyDetailsKeys.length);
|
||||
}
|
||||
updateMoneyDetailsAmount() {
|
||||
let total = Object.entries(this.state.moneyDetails).reduce((total, money) => total + money[0] * money[1], 0);
|
||||
this.state.total = this.env.pos.round_decimals_currency(total);
|
||||
}
|
||||
confirm() {
|
||||
let moneyDetailsNotes = this.state.total ? 'Money details: \n' : null;
|
||||
this.env.pos.bills.forEach(bill => {
|
||||
if (this.state.moneyDetails[bill.value]) {
|
||||
moneyDetailsNotes += ` - ${this.state.moneyDetails[bill.value]} x ${this.env.pos.format_currency(bill.value)}\n`;
|
||||
}
|
||||
})
|
||||
const payload = { total: this.state.total, moneyDetailsNotes, moneyDetails: { ...this.state.moneyDetails } };
|
||||
this.props.onConfirm(payload);
|
||||
}
|
||||
reset() {
|
||||
for (let key in this.state.moneyDetails) { this.state.moneyDetails[key] = 0 }
|
||||
this.state.total = 0;
|
||||
}
|
||||
discard() {
|
||||
this.reset();
|
||||
this.props.onDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
MoneyDetailsPopup.template = 'MoneyDetailsPopup';
|
||||
Registries.Component.add(MoneyDetailsPopup);
|
||||
|
||||
return MoneyDetailsPopup;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
odoo.define('point_of_sale.NumberPopup', function(require) {
|
||||
'use strict';
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const NumberBuffer = require('point_of_sale.NumberBuffer');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useState } = owl;
|
||||
|
||||
// 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.
|
||||
* @param {Boolean} props.isInputSelected Input is highlighted and will reset upon a change.
|
||||
*
|
||||
* Resolve to { confirmed, payload } when used with showPopup method.
|
||||
* @confirmed {Boolean}
|
||||
* @payload {String}
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
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().replace('.', this.decimalSeparator);
|
||||
}
|
||||
this.state = useState({ buffer: startingBuffer, toStartOver: this.props.isInputSelected });
|
||||
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;
|
||||
}
|
||||
}
|
||||
confirm(event) {
|
||||
if (NumberBuffer.get()) {
|
||||
super.confirm();
|
||||
}
|
||||
}
|
||||
sendInput(key) {
|
||||
this.trigger('numpad-click-input', { key });
|
||||
}
|
||||
getPayload() {
|
||||
return NumberBuffer.get();
|
||||
}
|
||||
}
|
||||
NumberPopup.template = 'NumberPopup';
|
||||
NumberPopup.defaultProps = {
|
||||
confirmText: _t('Ok'),
|
||||
cancelText: _t('Cancel'),
|
||||
title: _t('Confirm ?'),
|
||||
body: '',
|
||||
cheap: false,
|
||||
startingValue: null,
|
||||
isPassword: false,
|
||||
};
|
||||
|
||||
Registries.Component.add(NumberPopup);
|
||||
|
||||
return NumberPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
odoo.define('point_of_sale.OfflineErrorPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const ErrorPopup = require('point_of_sale.ErrorPopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
/**
|
||||
* 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: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
title: _lt('Offline Error'),
|
||||
body: _lt('Either the server is inaccessible or browser is not connected online.'),
|
||||
};
|
||||
|
||||
Registries.Component.add(OfflineErrorPopup);
|
||||
|
||||
return OfflineErrorPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
odoo.define('point_of_sale.OrderImportPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
// 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: _lt('Ok'),
|
||||
cancelKey: false,
|
||||
body: '',
|
||||
};
|
||||
|
||||
Registries.Component.add(OrderImportPopup);
|
||||
|
||||
return OrderImportPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
odoo.define('point_of_sale.PosPopupController', function(require) {
|
||||
'use strict';
|
||||
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const { useBus } = require('@web/core/utils/hooks');
|
||||
|
||||
/**
|
||||
* This component is responsible in controlling the popups. It does so
|
||||
* by coordinating with them thru the `env.posbus`. The basic steps follow:
|
||||
* 1. `showPopup` method triggers `show-popup` event resulting to the
|
||||
* mounting of the requested popup.
|
||||
* 2. When the popup is shown, the `confirm`/`cancel` method of the popup
|
||||
* will be called after the popup is used. `confirming`/`cancelling`
|
||||
* will trigger the `close-popup`, which this component also listens to,
|
||||
* resulting to closing of the popup.
|
||||
*
|
||||
* Furthermore, Pressing `confirmKey`/`cancelKey` which defaults to
|
||||
* 'Enter'/'Escape', will automatically `confirm`/`cancel` the `topPopup`.
|
||||
* This behavior is accomplished by listening to `keyup` event of the window.
|
||||
* When the `confirmKey`/`cancelKey` of the `topPopup` is pressed,
|
||||
* 'cancel-popup-{top-popup-id}'/'confirm-popup-{top-popup-id}' will be triggered
|
||||
* and since the popup is listening to that event (@see AbstractAwaitablePopup),
|
||||
* it will result to the call of `confirm`/`cancel` method.
|
||||
*
|
||||
* @typedef {{ id: number, resolve: Function, keepBehind?: boolean, cancelKey?: string, confirmKey?: string }} BasePopupProps
|
||||
* @typedef {{ name: string, component: AbstractAwaitablePopup, props: BasePopupProps, key: string }} Popup
|
||||
*/
|
||||
class PosPopupController extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useBus(this.env.posbus, 'show-popup', this._showPopup);
|
||||
useBus(this.env.posbus, 'close-popup', this._closePopup);
|
||||
owl.useExternalListener(window, 'keyup', this._onWindowKeyup);
|
||||
this.popups = owl.useState([]);
|
||||
}
|
||||
_showPopup(event) {
|
||||
let { id, name, props, resolve } = event.detail;
|
||||
props = Object.assign(props || {}, { id, resolve });
|
||||
const component = this.constructor.components[name];
|
||||
if (!component) {
|
||||
throw new Error(`'${name}' is not found. Make sure the file is loaded and the component is properly registered using 'Registries.Component.add'.`);
|
||||
}
|
||||
if (component.dontShow) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.popups.push({
|
||||
name,
|
||||
component,
|
||||
props: this._constructPopupProps(component, props),
|
||||
key: `${name}-${id}`,
|
||||
});
|
||||
}
|
||||
_closePopup(event) {
|
||||
const { popupId, response } = event.detail;
|
||||
const index = this.popups.findIndex((popup) => popup.props.id == popupId);
|
||||
if (index != -1) {
|
||||
const popup = this.popups[index];
|
||||
popup.props.resolve(response);
|
||||
this.popups.splice(index, 1);
|
||||
}
|
||||
}
|
||||
_onWindowKeyup(event) {
|
||||
const eventIsFromInputField = event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA';
|
||||
const shouldHandleKey = this.topPopup && !eventIsFromInputField;
|
||||
if (!shouldHandleKey) return;
|
||||
|
||||
if (event.key === this.topPopup.props.cancelKey) {
|
||||
this.env.posbus.trigger(`cancel-popup-${this.topPopup.props.id}`);
|
||||
} else if (event.key === this.topPopup.props.confirmKey) {
|
||||
this.env.posbus.trigger(`confirm-popup-${this.topPopup.props.id}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A popup can be cancelled/confirmed with 'Escape'/'Enter' key by default.
|
||||
* Also, if it's not the top popup, it is hidden from the view.
|
||||
* This can be overridden by the default props of the popop component
|
||||
* and the props used in requesting to show the popup.
|
||||
*
|
||||
* @param {AbstractAwaitablePopup} popupComponent
|
||||
* @param {Object} props
|
||||
* @returns {BasePopupProps}
|
||||
*/
|
||||
_constructPopupProps(popupComponent, props) {
|
||||
const defaultProps = popupComponent.defaultProps || {};
|
||||
return Object.assign(
|
||||
{
|
||||
keepBehind: false,
|
||||
cancelKey: 'Escape',
|
||||
confirmKey: 'Enter',
|
||||
},
|
||||
defaultProps,
|
||||
props
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @returns {boolean} Hide the element of this component when this returns false.
|
||||
*/
|
||||
isShown() {
|
||||
return this.popups.length > 0;
|
||||
}
|
||||
get topPopup() {
|
||||
return this.popups[this.popups.length - 1];
|
||||
}
|
||||
/**
|
||||
* By default, only show the top popup. But always show a popup if
|
||||
* `keepBehind` props is true. Meaning, if you have 2 popups, and
|
||||
* the bottom popup has `keepBehind = true`, then the bottom popup
|
||||
* will be visible if it's not blocked in the view by the top popup.
|
||||
*
|
||||
* @param {Popup} popup
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldShow(popup) {
|
||||
return this.topPopup === popup || popup.props.keepBehind;
|
||||
}
|
||||
}
|
||||
PosPopupController.template = 'point_of_sale.PosPopupController';
|
||||
Registries.Component.add(PosPopupController);
|
||||
|
||||
return PosPopupController;
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
odoo.define('point_of_sale.ProductConfiguratorPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useState, useSubEnv } = owl;
|
||||
|
||||
class ProductConfiguratorPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
useSubEnv({ attribute_components: [] });
|
||||
}
|
||||
|
||||
getPayload() {
|
||||
var selected_attributes = [];
|
||||
var price_extra = 0.0;
|
||||
|
||||
this.env.attribute_components.forEach((attribute_component) => {
|
||||
let { value, extra } = attribute_component.getValue();
|
||||
selected_attributes.push(value);
|
||||
price_extra += extra;
|
||||
});
|
||||
|
||||
return {
|
||||
selected_attributes,
|
||||
price_extra,
|
||||
};
|
||||
}
|
||||
}
|
||||
ProductConfiguratorPopup.template = 'ProductConfiguratorPopup';
|
||||
Registries.Component.add(ProductConfiguratorPopup);
|
||||
|
||||
class BaseProductAttribute extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.env.attribute_components.push(this);
|
||||
|
||||
this.attribute = this.props.attribute;
|
||||
this.values = this.attribute.values;
|
||||
this.state = useState({
|
||||
selected_value: parseFloat(this.values[0].id),
|
||||
custom_value: '',
|
||||
});
|
||||
}
|
||||
|
||||
getValue() {
|
||||
let selected_value = this.values.find((val) => val.id === parseFloat(this.state.selected_value));
|
||||
let value = selected_value.name;
|
||||
if (selected_value.is_custom && this.state.custom_value) {
|
||||
value += `: ${this.state.custom_value}`;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
extra: selected_value.price_extra
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class RadioProductAttribute extends BaseProductAttribute {
|
||||
setup() {
|
||||
super.setup();
|
||||
owl.onMounted(this.onMounted);
|
||||
}
|
||||
onMounted() {
|
||||
// With radio buttons `t-model` selects the default input by searching for inputs with
|
||||
// a matching `value` attribute. In our case, we use `t-att-value` so `value` is
|
||||
// not found yet and no radio is selected by default.
|
||||
// We then manually select the first input of each radio attribute.
|
||||
$(this.el).find('input[type="radio"]:first').prop('checked', true);
|
||||
}
|
||||
}
|
||||
RadioProductAttribute.template = 'RadioProductAttribute';
|
||||
Registries.Component.add(RadioProductAttribute);
|
||||
|
||||
class SelectProductAttribute extends BaseProductAttribute { }
|
||||
SelectProductAttribute.template = 'SelectProductAttribute';
|
||||
Registries.Component.add(SelectProductAttribute);
|
||||
|
||||
class ColorProductAttribute extends BaseProductAttribute {}
|
||||
ColorProductAttribute.template = 'ColorProductAttribute';
|
||||
Registries.Component.add(ColorProductAttribute);
|
||||
|
||||
return {
|
||||
ProductConfiguratorPopup,
|
||||
BaseProductAttribute,
|
||||
RadioProductAttribute,
|
||||
SelectProductAttribute,
|
||||
ColorProductAttribute,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
odoo.define('point_of_sale.ProductInfoPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* {
|
||||
* info: {object of data}
|
||||
* }
|
||||
*/
|
||||
class ProductInfoPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
Object.assign(this, this.props.info);
|
||||
}
|
||||
searchProduct(productName) {
|
||||
this.env.posbus.trigger('search-product-from-info-popup', productName);
|
||||
this.cancel()
|
||||
}
|
||||
_hasMarginsCostsAccessRights() {
|
||||
const isAccessibleToEveryUser = this.env.pos.config.is_margins_costs_accessible_to_every_user;
|
||||
const isCashierManager = this.env.pos.get_cashier().role === 'manager';
|
||||
return isAccessibleToEveryUser || isCashierManager;
|
||||
}
|
||||
}
|
||||
|
||||
ProductInfoPopup.template = 'ProductInfoPopup';
|
||||
ProductInfoPopup.defaultProps= { confirmKey: false };
|
||||
Registries.Component.add(ProductInfoPopup);
|
||||
|
||||
return ProductInfoPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
odoo.define('point_of_sale.SelectionPopup', function (require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
const { useState } = owl;
|
||||
|
||||
// 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,
|
||||
* }
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({ selectedId: this.props.list.find((item) => item.isSelected) });
|
||||
}
|
||||
selectItem(itemId) {
|
||||
this.state.selectedId = itemId;
|
||||
this.confirm();
|
||||
}
|
||||
/**
|
||||
* We send as payload of the response the selected item.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
getPayload() {
|
||||
const selected = this.props.list.find((item) => this.state.selectedId === item.id);
|
||||
return selected && selected.item;
|
||||
}
|
||||
}
|
||||
SelectionPopup.template = 'SelectionPopup';
|
||||
SelectionPopup.defaultProps = {
|
||||
cancelText: _lt('Cancel'),
|
||||
title: _lt('Select'),
|
||||
body: '',
|
||||
list: [],
|
||||
confirmKey: false,
|
||||
};
|
||||
|
||||
Registries.Component.add(SelectionPopup);
|
||||
|
||||
return SelectionPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
odoo.define('point_of_sale.TextAreaPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
const { onMounted, useRef, useState } = owl;
|
||||
|
||||
// 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
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({ inputValue: this.props.startingValue });
|
||||
this.inputRef = useRef('input');
|
||||
onMounted(this.onMounted);
|
||||
}
|
||||
onMounted() {
|
||||
this.inputRef.el.focus();
|
||||
}
|
||||
getPayload() {
|
||||
return this.state.inputValue;
|
||||
}
|
||||
}
|
||||
TextAreaPopup.template = 'TextAreaPopup';
|
||||
TextAreaPopup.defaultProps = {
|
||||
confirmText: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
title: '',
|
||||
body: '',
|
||||
};
|
||||
|
||||
Registries.Component.add(TextAreaPopup);
|
||||
|
||||
return TextAreaPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
odoo.define('point_of_sale.TextInputPopup', function(require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
const { onMounted, useRef, useState } = owl;
|
||||
|
||||
// formerly TextInputPopupWidget
|
||||
class TextInputPopup extends AbstractAwaitablePopup {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({ inputValue: this.props.startingValue });
|
||||
this.inputRef = useRef('input');
|
||||
onMounted(this.onMounted);
|
||||
}
|
||||
onMounted() {
|
||||
this.inputRef.el.focus();
|
||||
}
|
||||
getPayload() {
|
||||
return this.state.inputValue;
|
||||
}
|
||||
}
|
||||
TextInputPopup.template = 'TextInputPopup';
|
||||
TextInputPopup.defaultProps = {
|
||||
confirmText: _lt('Ok'),
|
||||
cancelText: _lt('Cancel'),
|
||||
title: '',
|
||||
body: '',
|
||||
startingValue: '',
|
||||
placeholder: '',
|
||||
};
|
||||
|
||||
Registries.Component.add(TextInputPopup);
|
||||
|
||||
return TextInputPopup;
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
odoo.define('point_of_sale.PosComponent', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { LegacyComponent } = require("@web/legacy/legacy_component");
|
||||
const { onRendered } = owl;
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
class PosComponent extends LegacyComponent {
|
||||
setup() {
|
||||
onRendered(() => {
|
||||
if (this.env.isDebug()) {
|
||||
console.log('Rendered:', this.constructor.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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.env.posbus.trigger('show-popup', { name, props, resolve, id: nextId++ });
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
showNotification(message, duration = 2000) {
|
||||
this.trigger('show-notification', { message, duration });
|
||||
}
|
||||
closeNotification() {
|
||||
this.trigger('close-notification');
|
||||
}
|
||||
}
|
||||
|
||||
return PosComponent;
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
odoo.define('point_of_sale.PosContext', function (require) {
|
||||
'use strict';
|
||||
const { reactive } = owl;
|
||||
|
||||
// Create global context objects
|
||||
// e.g. component.env.device = new Context({ isMobile: false });
|
||||
return {
|
||||
orderManagement: reactive({ searchString: '', selectedOrder: null }),
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
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');
|
||||
const ClassRegistry = require('point_of_sale.ClassRegistry');
|
||||
|
||||
class ModelRegistry extends ClassRegistry {
|
||||
add(baseClass) {
|
||||
super.add(baseClass);
|
||||
/**
|
||||
* Introduce a static method (`create`) to each base class that can be
|
||||
* conveniently use to create an instance of the extended version
|
||||
* of the class.
|
||||
*/
|
||||
baseClass.create = (...args) => {
|
||||
const ExtendedClass = this.get(baseClass);
|
||||
return new ExtendedClass(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { Component: new ComponentRegistry(), Model: new ModelRegistry() };
|
||||
});
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
odoo.define("point_of_sale.PartnerDetailsEdit", function (require) {
|
||||
"use strict";
|
||||
|
||||
const { _t } = require("web.core");
|
||||
const { getDataURLFromFile } = require("web.utils");
|
||||
const PosComponent = require("point_of_sale.PosComponent");
|
||||
const Registries = require("point_of_sale.Registries");
|
||||
|
||||
const { onMounted, useState, onWillUnmount } = owl;
|
||||
|
||||
class PartnerDetailsEdit extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.intFields = ["country_id", "state_id", "property_product_pricelist"];
|
||||
const partner = this.props.partner;
|
||||
this.changes = useState({
|
||||
name: partner.name || false,
|
||||
street: partner.street || false,
|
||||
city: partner.city || false,
|
||||
zip: partner.zip || false,
|
||||
state_id: partner.state_id && partner.state_id[0],
|
||||
country_id: partner.country_id && partner.country_id[0],
|
||||
lang: partner.lang || false,
|
||||
email: partner.email || false,
|
||||
phone: partner.phone || false,
|
||||
mobile: partner.mobile || false,
|
||||
barcode: partner.barcode || false,
|
||||
vat: partner.vat || false,
|
||||
property_product_pricelist: this.getDefaultPricelist(partner),
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this.env.bus.on("save-partner", this, this.saveChanges);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.env.bus.off("save-partner", this);
|
||||
});
|
||||
}
|
||||
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=avatar_128&unique=${partner.write_date}`;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
getDefaultPricelist(partner) {
|
||||
if (partner.property_product_pricelist) {
|
||||
return partner.property_product_pricelist[0];
|
||||
}
|
||||
return this.env.pos.default_pricelist ? this.env.pos.default_pricelist.id : false;
|
||||
}
|
||||
// NOTE: this functions was kept for compatibility with stable
|
||||
captureChange(event) {}
|
||||
saveChanges() {
|
||||
const processedChanges = {};
|
||||
for (const [key, value] of Object.entries(this.changes)) {
|
||||
if (this.intFields.includes(key)) {
|
||||
processedChanges[key] = parseInt(value) || false;
|
||||
} else {
|
||||
processedChanges[key] = value;
|
||||
}
|
||||
}
|
||||
if (
|
||||
processedChanges.state_id &&
|
||||
this.env.pos.states.find((state) => state.id === processedChanges.state_id)
|
||||
.country_id[0] !== processedChanges.country_id
|
||||
) {
|
||||
processedChanges.state_id = false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.props.partner.name && !processedChanges.name) ||
|
||||
processedChanges.name === ""
|
||||
) {
|
||||
return this.showPopup("ErrorPopup", {
|
||||
title: _t("A Customer Name Is Required"),
|
||||
});
|
||||
}
|
||||
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(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
_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;
|
||||
});
|
||||
}
|
||||
}
|
||||
PartnerDetailsEdit.template = "PartnerDetailsEdit";
|
||||
|
||||
Registries.Component.add(PartnerDetailsEdit);
|
||||
|
||||
return PartnerDetailsEdit;
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
odoo.define('point_of_sale.PartnerLine', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class PartnerLine extends PosComponent {
|
||||
get highlight() {
|
||||
return this._isPartnerSelected ? 'highlight' : '';
|
||||
}
|
||||
get shortAddress() {
|
||||
const { partner } = this.props;
|
||||
return partner.address;
|
||||
}
|
||||
get _isPartnerSelected() {
|
||||
return this.props.partner === this.props.selectedPartner;
|
||||
}
|
||||
}
|
||||
PartnerLine.template = 'PartnerLine';
|
||||
|
||||
Registries.Component.add(PartnerLine);
|
||||
|
||||
return PartnerLine;
|
||||
});
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
odoo.define('point_of_sale.PartnerListScreen', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
|
||||
const { debounce } = require("@web/core/utils/timing");
|
||||
const { useListener, useAutofocus } = require("@web/core/utils/hooks");
|
||||
const { useAsyncLockedMethod } = require("point_of_sale.custom_hooks");
|
||||
const { session } = require("@web/session");
|
||||
|
||||
const { onWillUnmount, useRef } = owl;
|
||||
|
||||
/**
|
||||
* Render this screen using `showTempScreen` to select partner.
|
||||
* When the shown screen is confirmed ('Set Customer' or 'Deselect Customer'
|
||||
* button is clicked), the call to `showTempScreen` resolves to the
|
||||
* selected partner. E.g.
|
||||
*
|
||||
* ```js
|
||||
* const { confirmed, payload: selectedPartner } = await showTempScreen('PartnerListScreen');
|
||||
* if (confirmed) {
|
||||
* // do something with the selectedPartner
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @props partner - originally selected partner
|
||||
*/
|
||||
class PartnerListScreen extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useAutofocus({refName: 'search-word-input-partner'});
|
||||
useListener('click-save', () => this.env.bus.trigger('save-partner'));
|
||||
useListener('save-changes', useAsyncLockedMethod(this.saveChanges));
|
||||
this.searchWordInputRef = useRef('search-word-input-partner');
|
||||
|
||||
// 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 Observer. But it
|
||||
// is better to be safe.
|
||||
this.state = {
|
||||
query: null,
|
||||
selectedPartner: this.props.partner,
|
||||
detailIsShown: false,
|
||||
editModeProps: {
|
||||
partner: null,
|
||||
},
|
||||
previousQuery: "",
|
||||
currentOffset: 0,
|
||||
};
|
||||
this.updatePartnerList = debounce(this.updatePartnerList, 70);
|
||||
onWillUnmount(this.updatePartnerList.cancel);
|
||||
}
|
||||
// Lifecycle hooks
|
||||
back() {
|
||||
if(this.state.detailIsShown) {
|
||||
this.state.detailIsShown = false;
|
||||
this.render(true);
|
||||
} else {
|
||||
this.props.resolve({ confirmed: false, payload: false });
|
||||
this.trigger('close-temp-screen');
|
||||
}
|
||||
}
|
||||
confirm() {
|
||||
this.props.resolve({ confirmed: true, payload: this.state.selectedPartner });
|
||||
this.trigger('close-temp-screen');
|
||||
}
|
||||
activateEditMode() {
|
||||
this.state.detailIsShown = true;
|
||||
this.render(true);
|
||||
}
|
||||
// Getters
|
||||
|
||||
get currentOrder() {
|
||||
return this.env.pos.get_order();
|
||||
}
|
||||
|
||||
get partners() {
|
||||
let res;
|
||||
if (this.state.query && this.state.query.trim() !== '') {
|
||||
res = this.env.pos.db.search_partner(this.state.query.trim());
|
||||
} else {
|
||||
res = this.env.pos.db.get_partners_sorted(1000);
|
||||
}
|
||||
res.sort(function (a, b) { return (a.name || '').localeCompare(b.name || '') });
|
||||
// the selected partner (if any) is displayed at the top of the list
|
||||
if (this.state.selectedPartner) {
|
||||
let indexOfSelectedPartner = res.findIndex( partner =>
|
||||
partner.id === this.state.selectedPartner.id
|
||||
);
|
||||
if (indexOfSelectedPartner !== -1) {
|
||||
res.splice(indexOfSelectedPartner, 1);
|
||||
}
|
||||
res.unshift(this.state.selectedPartner);
|
||||
}
|
||||
return res
|
||||
}
|
||||
get isBalanceDisplayed() {
|
||||
return false;
|
||||
}
|
||||
get partnerLink() {
|
||||
return `/web#model=res.partner&id=${this.state.editModeProps.partner.id}`;
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
async _onPressEnterKey() {
|
||||
if (!this.state.query) return;
|
||||
const result = await this.searchPartner();
|
||||
if (result.length > 0) {
|
||||
this.showNotification(
|
||||
_.str.sprintf(
|
||||
this.env._t('%s customer(s) found for "%s".'),
|
||||
result.length,
|
||||
this.state.query
|
||||
),
|
||||
3000
|
||||
);
|
||||
} else {
|
||||
this.showNotification(
|
||||
_.str.sprintf(
|
||||
this.env._t('No more customer found for "%s".'),
|
||||
this.state.query
|
||||
),
|
||||
3000
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
_clearSearch() {
|
||||
this.searchWordInputRef.el.value = '';
|
||||
this.state.query = '';
|
||||
this.render(true);
|
||||
}
|
||||
// We declare this event handler as a debounce function in
|
||||
// order to lower its trigger rate.
|
||||
async updatePartnerList(event) {
|
||||
this.state.query = event.target.value;
|
||||
this.render(true);
|
||||
}
|
||||
clickPartner(partner) {
|
||||
if (this.state.selectedPartner && this.state.selectedPartner.id === partner.id) {
|
||||
this.state.selectedPartner = null;
|
||||
} else {
|
||||
this.state.selectedPartner = partner;
|
||||
}
|
||||
this.confirm();
|
||||
}
|
||||
editPartner(partner) {
|
||||
this.state.editModeProps.partner = partner;
|
||||
this.activateEditMode();
|
||||
}
|
||||
createPartner() {
|
||||
// initialize the edit screen with default details about country, state & lang
|
||||
this.state.editModeProps.partner = {
|
||||
country_id: this.env.pos.company.country_id,
|
||||
state_id: this.env.pos.company.state_id,
|
||||
lang: session.user_context.lang,
|
||||
}
|
||||
this.activateEditMode();
|
||||
}
|
||||
async saveChanges(event) {
|
||||
try {
|
||||
let partnerId = await this.rpc({
|
||||
model: 'res.partner',
|
||||
method: 'create_from_ui',
|
||||
args: [event.detail.processedChanges],
|
||||
});
|
||||
await this.env.pos._loadPartners([partnerId]);
|
||||
this.state.selectedPartner = this.env.pos.db.get_partner_by_id(partnerId);
|
||||
this.confirm();
|
||||
} catch (error) {
|
||||
if (isConnectionError(error)) {
|
||||
await this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Offline'),
|
||||
body: this.env._t('Unable to save changes.'),
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
async searchPartner() {
|
||||
if (this.state.previousQuery != this.state.query) {
|
||||
this.state.currentOffset = 0;
|
||||
}
|
||||
let result = await this.getNewPartners();
|
||||
this.env.pos.addPartners(result);
|
||||
this.render(true);
|
||||
if (this.state.previousQuery == this.state.query) {
|
||||
this.state.currentOffset += result.length;
|
||||
} else {
|
||||
this.state.previousQuery = this.state.query;
|
||||
this.state.currentOffset = result.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async getNewPartners() {
|
||||
let domain = [];
|
||||
const limit = 30;
|
||||
if(this.state.query) {
|
||||
const search_fields = [
|
||||
"name",
|
||||
"parent_name",
|
||||
"phone",
|
||||
"mobile",
|
||||
"email",
|
||||
"vat",
|
||||
];
|
||||
domain = [
|
||||
...Array(search_fields.length - 1).fill('|'),
|
||||
...search_fields.map(field => [field, "ilike", this.state.query + "%"])
|
||||
];
|
||||
}
|
||||
const result = await this.env.services.rpc(
|
||||
{
|
||||
model: 'pos.session',
|
||||
method: 'get_pos_ui_res_partner_by_params',
|
||||
args: [
|
||||
[odoo.pos_session_id],
|
||||
{
|
||||
domain,
|
||||
limit: limit,
|
||||
offset: this.state.currentOffset,
|
||||
},
|
||||
],
|
||||
context: this.env.session.user_context,
|
||||
},
|
||||
{
|
||||
timeout: 3000,
|
||||
shadow: true,
|
||||
}
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
PartnerListScreen.template = 'PartnerListScreen';
|
||||
|
||||
Registries.Component.add(PartnerListScreen);
|
||||
|
||||
return PartnerListScreen;
|
||||
});
|
||||
|
|
@ -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,538 @@
|
|||
odoo.define('point_of_sale.PaymentScreen', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { parse } = require('web.field_utils');
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const { useErrorHandlers, useAsyncLockedMethod } = require('point_of_sale.custom_hooks');
|
||||
const NumberBuffer = require('point_of_sale.NumberBuffer');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
const utils = require('web.utils');
|
||||
const round_pr = utils.round_precision;
|
||||
|
||||
class PaymentScreen extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
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);
|
||||
useListener('validate-order', () => this.validateOrder(false));
|
||||
this.payment_methods_from_config = this.env.pos.payment_methods.filter(method => this.env.pos.config.payment_method_ids.includes(method.id));
|
||||
NumberBuffer.use(this._getNumberBufferConfig);
|
||||
useErrorHandlers();
|
||||
this.payment_interface = null;
|
||||
this.error = false;
|
||||
this.validateOrder = useAsyncLockedMethod(this.validateOrder);
|
||||
}
|
||||
|
||||
showMaxValueError() {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Maximum value reached'),
|
||||
body: this.env._t('The amount cannot be higher than the due amount if you don\'t have a cash payment method configured.')
|
||||
});
|
||||
}
|
||||
get _getNumberBufferConfig() {
|
||||
let config = {
|
||||
// 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',
|
||||
useWithBarcode: true,
|
||||
};
|
||||
// Check if pos has a cash payment method
|
||||
const hasCashPaymentMethod = this.payment_methods_from_config.some(
|
||||
(method) => method.type === 'cash'
|
||||
);
|
||||
|
||||
if (!hasCashPaymentMethod) {
|
||||
config['maxValue'] = this.currentOrder.get_due();
|
||||
config['maxValueReached'] = this.showMaxValueError.bind(this);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
get currentOrder() {
|
||||
return this.env.pos.get_order();
|
||||
}
|
||||
get paymentLines() {
|
||||
return this.currentOrder.get_paymentlines();
|
||||
}
|
||||
get selectedPaymentLine() {
|
||||
return this.currentOrder.selected_paymentline;
|
||||
}
|
||||
async selectPartner() {
|
||||
// IMPROVEMENT: This code snippet is repeated multiple times.
|
||||
// Maybe it's better to create a function for it.
|
||||
const currentPartner = this.currentOrder.get_partner();
|
||||
const { confirmed, payload: newPartner } = await this.showTempScreen(
|
||||
'PartnerListScreen',
|
||||
{ partner: currentPartner }
|
||||
);
|
||||
if (confirmed) {
|
||||
this.currentOrder.set_partner(newPartner);
|
||||
this.currentOrder.updatePricelist(newPartner);
|
||||
}
|
||||
}
|
||||
addNewPaymentLine({ detail: paymentMethod }) {
|
||||
// original function: click_paymentmethods
|
||||
if(!this.env.pos.get_order().check_paymentlines_rounding()) {
|
||||
this._display_popup_error_paymentlines_rounding();
|
||||
return false;
|
||||
}
|
||||
let result = this.currentOrder.add_paymentline(paymentMethod);
|
||||
if (result){
|
||||
NumberBuffer.reset();
|
||||
return true;
|
||||
}
|
||||
else{
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Error'),
|
||||
body: this.env._t('There is already an electronic payment in progress.'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_display_popup_error_paymentlines_rounding() {
|
||||
if(this.env.pos.config.cash_rounding) {
|
||||
const orderlines = this.paymentLines;
|
||||
const cash_rounding = this.env.pos.cash_rounding[0].rounding;
|
||||
const default_rounding = this.env.pos.currency.rounding;
|
||||
for(var id in orderlines) {
|
||||
var line = orderlines[id];
|
||||
var diff = round_pr(round_pr(line.amount, cash_rounding) - round_pr(line.amount, default_rounding), default_rounding);
|
||||
|
||||
if(diff && (line.payment_method.is_cash_count || !this.env.pos.config.only_round_cash_method)) {
|
||||
const upper_amount = round_pr(round_pr(line.amount, default_rounding) + cash_rounding / 2, cash_rounding)
|
||||
const lower_amount = round_pr(round_pr(line.amount, default_rounding) - cash_rounding / 2, cash_rounding)
|
||||
this.showPopup("ErrorPopup", {
|
||||
title: this.env._t("Rounding error in payment lines"),
|
||||
body: _.str.sprintf(
|
||||
this.env._t(
|
||||
"The amount of your payment lines must be rounded to validate the transaction.\n" +
|
||||
"The rounding precision is %s so you should set %s or %s as payment amount instead of %s."
|
||||
),
|
||||
cash_rounding.toFixed(this.env.pos.currency.decimal_places),
|
||||
lower_amount.toFixed(this.env.pos.currency.decimal_places),
|
||||
upper_amount.toFixed(this.env.pos.currency.decimal_places),
|
||||
line.amount.toFixed(this.env.pos.currency.decimal_places)
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_updateSelectedPaymentline() {
|
||||
if (this.paymentLines.every((line) => line.paid)) {
|
||||
this.currentOrder.add_paymentline(this.payment_methods_from_config[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
|
||||
const payment_terminal = this.selectedPaymentLine.payment_method.payment_terminal;
|
||||
if (
|
||||
payment_terminal &&
|
||||
!['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(true);
|
||||
}
|
||||
openCashbox() {
|
||||
this.env.proxy.printer.open_cashbox();
|
||||
}
|
||||
async addTip() {
|
||||
// click_tip
|
||||
const tip = this.currentOrder.get_tip();
|
||||
const change = this.currentOrder.get_change();
|
||||
let value = tip === 0 && change > 0 ? change : tip;
|
||||
|
||||
const { confirmed, payload } = await this.showPopup('NumberPopup', {
|
||||
title: tip ? this.env._t('Change Tip') : this.env._t('Add Tip'),
|
||||
startingValue: value,
|
||||
isInputSelected: true,
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.currentOrder.set_tip(parse.float(payload));
|
||||
}
|
||||
}
|
||||
toggleIsToShip() {
|
||||
// click_ship
|
||||
this.currentOrder.set_to_ship(!this.currentOrder.is_to_ship());
|
||||
this.render(true);
|
||||
}
|
||||
deletePaymentLine(event) {
|
||||
var self = this;
|
||||
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.set_payment_status('waitingCancel');
|
||||
line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid).then(function() {
|
||||
self.currentOrder.remove_paymentline(line);
|
||||
NumberBuffer.reset();
|
||||
self.render(true);
|
||||
})
|
||||
}
|
||||
else if (line.get_payment_status() !== 'waitingCancel') {
|
||||
this.currentOrder.remove_paymentline(line);
|
||||
NumberBuffer.reset();
|
||||
this.render(true);
|
||||
}
|
||||
}
|
||||
selectPaymentLine(event) {
|
||||
const { cid } = event.detail;
|
||||
const line = this.paymentLines.find((line) => line.cid === cid);
|
||||
this.currentOrder.select_paymentline(line);
|
||||
NumberBuffer.reset();
|
||||
this.render(true);
|
||||
}
|
||||
async validateOrder(isForceValidate) {
|
||||
if(this.env.pos.config.cash_rounding) {
|
||||
if(!this.env.pos.get_order().check_paymentlines_rounding()) {
|
||||
this._display_popup_error_paymentlines_rounding();
|
||||
return;
|
||||
}
|
||||
}
|
||||
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 doInvoice(accountMoveId) {
|
||||
const actionRecord = this.env.pos.invoiceActionRecord;
|
||||
if (actionRecord && this.env.pos.shouldInvoiceNewTab(actionRecord)) {
|
||||
return this.env.legacyActionManager.do_action({
|
||||
type: "ir.actions.act_url",
|
||||
url: `/report/pdf/${actionRecord.report_name}/${accountMoveId}`,
|
||||
});
|
||||
}
|
||||
return this.env.legacyActionManager.do_action(this.env.pos.invoiceReportAction, {
|
||||
additional_context: {
|
||||
active_ids: [accountMoveId],
|
||||
},
|
||||
});
|
||||
}
|
||||
async _finalizeValidation() {
|
||||
if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.env.pos.config.iface_cashdrawer && this.env.proxy && this.env.proxy.printer) {
|
||||
this.env.proxy.printer.open_cashbox();
|
||||
}
|
||||
|
||||
this.currentOrder.initialize_validation_date();
|
||||
for (let line of this.paymentLines) {
|
||||
if (!line.amount === 0) {
|
||||
this.currentOrder.remove_paymentline(line);
|
||||
}
|
||||
}
|
||||
this.currentOrder.finalized = true;
|
||||
|
||||
let syncOrderResult, hasError;
|
||||
|
||||
try {
|
||||
this.env.services.ui.block()
|
||||
// 1. Save order to server.
|
||||
syncOrderResult = await this.env.pos.push_single_order(this.currentOrder);
|
||||
|
||||
// 2. Invoice.
|
||||
if (this.shouldDownloadInvoice() && this.currentOrder.is_to_invoice()) {
|
||||
if (syncOrderResult.length) {
|
||||
await this.doInvoice(syncOrderResult[0].account_move);
|
||||
} else {
|
||||
throw { code: 401, message: 'Backend Invoice', data: { order: this.currentOrder } };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Post process.
|
||||
if (syncOrderResult.length && this.currentOrder.wait_for_push_order()) {
|
||||
const postPushResult = await this._postPushOrderResolve(
|
||||
this.currentOrder,
|
||||
syncOrderResult.map((res) => res.id)
|
||||
);
|
||||
if (!postPushResult) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Error: no internet connection.'),
|
||||
body: this.env._t('Some, if not all, post-processing after syncing order failed.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// unblock the UI before showing the error popup
|
||||
this.env.services.ui.unblock();
|
||||
if (error.code == 700 || error.code == 701)
|
||||
this.error = true;
|
||||
|
||||
if ('code' in error) {
|
||||
// We started putting `code` in the rejected object for invoicing error.
|
||||
// We can continue with that convention such that when the error has `code`,
|
||||
// then it is an error when invoicing. Besides, _handlePushOrderError was
|
||||
// introduce to handle invoicing error logic.
|
||||
await this._handlePushOrderError(error);
|
||||
} else {
|
||||
// We don't block for connection error. But we rethrow for any other errors.
|
||||
if (isConnectionError(error)) {
|
||||
this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Connection Error'),
|
||||
body: this.env._t('Order is not synced. Check your internet connection'),
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.env.services.ui.unblock()
|
||||
// Always show the next screen regardless of error since pos has to
|
||||
// continue working even offline.
|
||||
this.showScreen(this.nextScreen);
|
||||
// Remove the order from the local storage so that when we refresh the page, the order
|
||||
// won't be there
|
||||
this.env.pos.db.remove_unpaid_order(this.currentOrder);
|
||||
|
||||
// Ask the user to sync the remaining unsynced orders.
|
||||
if (!hasError && syncOrderResult && 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* This method is meant to be overriden by localization that do not want to print the invoice pdf
|
||||
* every time they create an account move. For example, it can be overriden like this:
|
||||
* ```
|
||||
* shouldDownloadInvoice() {
|
||||
* const currentCountry = ...
|
||||
* if (currentCountry.code === 'FR') {
|
||||
* return false;
|
||||
* } else {
|
||||
* return super.shouldDownloadInvoice(); // or this._super(...arguments) depending on the odoo version.
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* @returns {boolean} true if the invoice pdf should be downloaded
|
||||
*/
|
||||
shouldDownloadInvoice() {
|
||||
return true;
|
||||
}
|
||||
get nextScreen() {
|
||||
return !this.error? 'ReceiptScreen' : 'ProductScreen';
|
||||
}
|
||||
async _isOrderValid(isForceValidate) {
|
||||
if (this.currentOrder.get_orderlines().length === 0 && this.currentOrder.is_to_invoice()) {
|
||||
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 and invoiced.'
|
||||
),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.currentOrder.electronic_payment_in_progress()) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Pending Electronic Payments'),
|
||||
body: this.env._t(
|
||||
'There is at least one pending electronic payment.\n' +
|
||||
'Please finish the payment with the terminal or ' +
|
||||
'cancel it then remove the payment line.'
|
||||
),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const splitPayments = this.paymentLines.filter(payment => payment.payment_method.split_transactions)
|
||||
if (splitPayments.length && !this.currentOrder.get_partner()) {
|
||||
const paymentMethod = splitPayments[0].payment_method
|
||||
const { confirmed } = await this.showPopup('ConfirmPopup', {
|
||||
title: this.env._t('Customer Required'),
|
||||
body: _.str.sprintf(this.env._t('Customer is required for %s payment method.'), paymentMethod.name),
|
||||
});
|
||||
if (confirmed) {
|
||||
this.selectPartner();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((this.currentOrder.is_to_invoice() || this.currentOrder.is_to_ship()) && !this.currentOrder.get_partner()) {
|
||||
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 or ship an order.'
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
this.selectPartner();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let partner = this.currentOrder.get_partner()
|
||||
if (this.currentOrder.is_to_ship() && !(partner.name && partner.street && partner.city && partner.country_id)) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Incorrect address for shipping'),
|
||||
body: this.env._t('The selected customer needs an address.'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.currentOrder.get_total_with_tax() != 0 && this.currentOrder.get_paymentlines().length === 0) {
|
||||
this.showNotification(this.env._t('Select a payment method to validate the order.'));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!this.currentOrder._isValidEmptyOrder()) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
async _postPushOrderResolve(order, order_server_ids) {
|
||||
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 = payment_terminal.supports_reversals;
|
||||
// Automatically validate the order when after an electronic payment,
|
||||
// the current order is fully paid and due is zero.
|
||||
if (
|
||||
this.currentOrder.is_paid() &&
|
||||
utils.float_is_zero(this.currentOrder.get_due(), this.env.pos.currency.decimal_places)
|
||||
) {
|
||||
this.trigger('validate-order');
|
||||
}
|
||||
} else {
|
||||
line.set_payment_status('retry');
|
||||
}
|
||||
}
|
||||
async _sendPaymentCancel({ detail: line }) {
|
||||
const payment_terminal = line.payment_method.payment_terminal;
|
||||
line.set_payment_status('waitingCancel');
|
||||
const isCancelSuccessful = await payment_terminal.send_payment_cancel(this.currentOrder, line.cid);
|
||||
if (isCancelSuccessful) {
|
||||
line.set_payment_status('retry');
|
||||
} else {
|
||||
line.set_payment_status('waitingCard');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
PaymentScreen.template = 'PaymentScreen';
|
||||
|
||||
Registries.Component.add(PaymentScreen);
|
||||
|
||||
return PaymentScreen;
|
||||
});
|
||||
|
|
@ -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 {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.decimalPoint = this.env._t.database.parameters.decimal_point;
|
||||
}
|
||||
}
|
||||
PaymentScreenNumpad.template = 'PaymentScreenNumpad';
|
||||
|
||||
Registries.Component.add(PaymentScreenNumpad);
|
||||
|
||||
return PaymentScreenNumpad;
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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());
|
||||
}
|
||||
selectedLineClass(line) {
|
||||
return { 'payment-terminal': line.get_payment_status() };
|
||||
}
|
||||
unselectedLineClass(line) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
PaymentScreenPaymentLines.template = 'PaymentScreenPaymentLines';
|
||||
|
||||
Registries.Component.add(PaymentScreenPaymentLines);
|
||||
|
||||
return PaymentScreenPaymentLines;
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
odoo.define('point_of_sale.PaymentScreenStatus', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class PaymentScreenStatus extends PosComponent {
|
||||
get changeText() {
|
||||
return this.env.pos.format_currency(this.props.order.get_change());
|
||||
}
|
||||
get totalDueText() {
|
||||
return this.env.pos.format_currency(
|
||||
this.props.order.get_total_with_tax() + this.props.order.get_rounding_applied()
|
||||
);
|
||||
}
|
||||
get remainingText() {
|
||||
return this.env.pos.format_currency(
|
||||
this.props.order.get_due() > 0 ? this.props.order.get_due() : 0
|
||||
);
|
||||
}
|
||||
}
|
||||
PaymentScreenStatus.template = 'PaymentScreenStatus';
|
||||
|
||||
Registries.Component.add(PaymentScreenStatus);
|
||||
|
||||
return PaymentScreenStatus;
|
||||
});
|
||||
|
|
@ -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 partner
|
||||
* @emits click-partner
|
||||
* @emits click-pay
|
||||
*/
|
||||
class ActionpadWidget extends PosComponent {
|
||||
get isLongName() {
|
||||
return this.props.partner && this.props.partner.name.length > 10;
|
||||
}
|
||||
}
|
||||
ActionpadWidget.template = 'ActionpadWidget';
|
||||
ActionpadWidget.defaultProps = {
|
||||
isActionButtonHighlighted: false,
|
||||
}
|
||||
|
||||
Registries.Component.add(ActionpadWidget);
|
||||
|
||||
return ActionpadWidget;
|
||||
});
|
||||
|
|
@ -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}&unique=${category.write_date}`;
|
||||
}
|
||||
}
|
||||
CategoryButton.template = 'CategoryButton';
|
||||
|
||||
Registries.Component.add(CategoryButton);
|
||||
|
||||
return CategoryButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
odoo.define('point_of_sale.OrderlineCustomerNoteButton', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const ProductScreen = require('point_of_sale.ProductScreen');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class OrderlineCustomerNoteButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this.onClick);
|
||||
}
|
||||
async onClick() {
|
||||
const selectedOrderline = this.env.pos.get_order().get_selected_orderline();
|
||||
if (!selectedOrderline) return;
|
||||
|
||||
const { confirmed, payload: inputNote } = await this.showPopup('TextAreaPopup', {
|
||||
startingValue: selectedOrderline.get_customer_note(),
|
||||
title: this.env._t('Add Customer Note'),
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
selectedOrderline.set_customer_note(inputNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
OrderlineCustomerNoteButton.template = 'OrderlineCustomerNoteButton';
|
||||
|
||||
ProductScreen.addControlButton({
|
||||
component: OrderlineCustomerNoteButton,
|
||||
});
|
||||
|
||||
Registries.Component.add(OrderlineCustomerNoteButton);
|
||||
|
||||
return OrderlineCustomerNoteButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
odoo.define('point_of_sale.ProductInfoButton', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const ProductScreen = require('point_of_sale.ProductScreen');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
|
||||
class ProductInfoButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this.onClick);
|
||||
}
|
||||
async onClick() {
|
||||
const orderline = this.env.pos.get_order().get_selected_orderline();
|
||||
if (orderline) {
|
||||
const product = orderline.get_product();
|
||||
const quantity = orderline.get_quantity();
|
||||
try {
|
||||
const info = await this.env.pos.getProductInfo(product, quantity);
|
||||
this.showPopup('ProductInfoPopup', { info: info , product: product });
|
||||
} catch (e) {
|
||||
if (isConnectionError(e)) {
|
||||
this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t('Cannot access product information screen if offline.'),
|
||||
});
|
||||
} else {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Unknown error'),
|
||||
body: this.env._t('An unknown error prevents us from loading product information.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProductInfoButton.template = 'ProductInfoButton';
|
||||
|
||||
ProductScreen.addControlButton({
|
||||
component: ProductInfoButton,
|
||||
position: ['before', 'SetFiscalPositionButton'],
|
||||
});
|
||||
|
||||
Registries.Component.add(ProductInfoButton);
|
||||
|
||||
return ProductInfoButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
odoo.define('point_of_sale.RefundButton', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const ProductScreen = require('point_of_sale.ProductScreen');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
|
||||
class RefundButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this._onClick);
|
||||
}
|
||||
_onClick() {
|
||||
const partner = this.env.pos.get_order().get_partner();
|
||||
const searchDetails = partner ? { fieldName: 'PARTNER', searchTerm: partner.name } : {};
|
||||
this.showScreen('TicketScreen', {
|
||||
ui: { filter: 'SYNCED', searchDetails },
|
||||
destinationOrder: this.env.pos.get_order(),
|
||||
});
|
||||
}
|
||||
}
|
||||
RefundButton.template = 'point_of_sale.RefundButton';
|
||||
|
||||
ProductScreen.addControlButton({
|
||||
component: RefundButton,
|
||||
condition: function () {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
Registries.Component.add(RefundButton);
|
||||
|
||||
return RefundButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
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/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class SetFiscalPositionButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this.onClick);
|
||||
}
|
||||
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.set_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) {
|
||||
line.set_quantity(line.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SetFiscalPositionButton.template = 'SetFiscalPositionButton';
|
||||
|
||||
ProductScreen.addControlButton({
|
||||
component: SetFiscalPositionButton,
|
||||
condition: function() {
|
||||
return this.env.pos.fiscal_positions.length > 0;
|
||||
},
|
||||
position: ['before', 'SetPricelistButton'],
|
||||
});
|
||||
|
||||
Registries.Component.add(SetFiscalPositionButton);
|
||||
|
||||
return SetFiscalPositionButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
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/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class SetPricelistButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this.onClick);
|
||||
}
|
||||
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,49 @@
|
|||
odoo.define('point_of_sale.NumpadWidget', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
/**
|
||||
* @prop {'quantity' | 'price' | 'discount'} activeMode
|
||||
* @prop {Array<'quantity' | 'price' | 'discount'>} disabledModes
|
||||
* @prop {boolean} disableSign
|
||||
* @event set-numpad-mode - triggered when mode button is clicked
|
||||
* @event numpad-click-input - triggered when numpad button is clicked
|
||||
*/
|
||||
class NumpadWidget extends PosComponent {
|
||||
get hasPriceControlRights() {
|
||||
return (
|
||||
this.env.pos.cashierHasPriceControlRights() &&
|
||||
!this.props.disabledModes.includes('price')
|
||||
);
|
||||
}
|
||||
get hasManualDiscount() {
|
||||
return this.env.pos.config.manual_discount && !this.props.disabledModes.includes('discount');
|
||||
}
|
||||
changeMode(mode) {
|
||||
if (!this.hasPriceControlRights && mode === 'price') {
|
||||
return;
|
||||
}
|
||||
if (!this.hasManualDiscount && mode === 'discount') {
|
||||
return;
|
||||
}
|
||||
this.trigger('set-numpad-mode', { mode });
|
||||
}
|
||||
sendInput(key) {
|
||||
this.trigger('numpad-click-input', { key });
|
||||
}
|
||||
get decimalSeparator() {
|
||||
return this.env._t.database.parameters.decimal_point;
|
||||
}
|
||||
}
|
||||
NumpadWidget.template = 'NumpadWidget';
|
||||
NumpadWidget.defaultProps = {
|
||||
disabledModes: [],
|
||||
disableSign: false,
|
||||
}
|
||||
|
||||
Registries.Component.add(NumpadWidget);
|
||||
|
||||
return NumpadWidget;
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
odoo.define('point_of_sale.OrderSummary', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { float_is_zero } = require('web.utils');
|
||||
|
||||
class OrderSummary extends PosComponent {
|
||||
getTotal() {
|
||||
return this.env.pos.format_currency(this.props.order.get_total_with_tax());
|
||||
}
|
||||
getTax() {
|
||||
const total = this.props.order.get_total_with_tax();
|
||||
const totalWithoutTax = this.props.order.get_total_without_tax();
|
||||
const taxAmount = total - totalWithoutTax;
|
||||
return {
|
||||
hasTax: !float_is_zero(taxAmount, this.env.pos.currency.decimal_places),
|
||||
displayAmount: this.env.pos.format_currency(taxAmount),
|
||||
};
|
||||
}
|
||||
}
|
||||
OrderSummary.template = 'OrderSummary';
|
||||
|
||||
Registries.Component.add(OrderSummary);
|
||||
|
||||
return OrderSummary;
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
odoo.define('point_of_sale.OrderWidget', function(require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { useRef, useEffect } = owl;
|
||||
|
||||
class OrderWidget extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('select-line', this._selectLine);
|
||||
useListener('edit-pack-lot-lines', this._editPackLotLines);
|
||||
this.scrollableRef = useRef('scrollable');
|
||||
useEffect(
|
||||
() => {
|
||||
const selectedLineEl = this.scrollableRef.el && this.scrollableRef.el.querySelector(".orderline.selected");
|
||||
if(selectedLineEl) {
|
||||
selectedLineEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
},
|
||||
() => [this.order.selected_orderline]
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
OrderWidget.template = 'OrderWidget';
|
||||
|
||||
Registries.Component.add(OrderWidget);
|
||||
|
||||
return OrderWidget;
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
get customerNote() {
|
||||
return this.props.line.get_customer_note();
|
||||
}
|
||||
}
|
||||
Orderline.template = 'Orderline';
|
||||
|
||||
Registries.Component.add(Orderline);
|
||||
|
||||
return Orderline;
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
odoo.define('point_of_sale.ProductItem', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
|
||||
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}&unique=${product.__last_update}`;
|
||||
}
|
||||
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_display_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;
|
||||
}
|
||||
}
|
||||
async onProductInfoClick() {
|
||||
try {
|
||||
const info = await this.env.pos.getProductInfo(this.props.product, 1);
|
||||
this.showPopup('ProductInfoPopup', { info: info , product: this.props.product });
|
||||
} catch (e) {
|
||||
if (isConnectionError(e)) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('OfflineErrorPopup'),
|
||||
body: this.env._t('Cannot access product information screen if offline.'),
|
||||
});
|
||||
} else {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Unknown error'),
|
||||
body: this.env._t('An unknown error prevents us from loading product information.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProductItem.template = 'ProductItem';
|
||||
|
||||
Registries.Component.add(ProductItem);
|
||||
|
||||
return ProductItem;
|
||||
});
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
odoo.define('point_of_sale.ProductScreen', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
|
||||
const NumberBuffer = require('point_of_sale.NumberBuffer');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { useBarcodeReader } = require('point_of_sale.custom_hooks');
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
const { parse } = require('web.field_utils');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
const { onMounted, useState } = owl;
|
||||
|
||||
class ProductScreen extends ControlButtonsMixin(PosComponent) {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('update-selected-orderline', (...args) => {
|
||||
if (!this.env.pos.tempScreenIsShown) this._updateSelectedOrderline(...args);
|
||||
});
|
||||
useListener('select-line', this._selectLine);
|
||||
useListener('set-numpad-mode', this._setNumpadMode);
|
||||
useListener('click-product', this._clickProduct);
|
||||
useListener('click-partner', this.onClickPartner);
|
||||
useListener('click-pay', this._onClickPay);
|
||||
useBarcodeReader({
|
||||
product: this._barcodeProductAction,
|
||||
quantity: this._barcodeProductAction,
|
||||
weight: this._barcodeProductAction,
|
||||
price: this._barcodeProductAction,
|
||||
client: this._barcodePartnerAction,
|
||||
discount: this._barcodeDiscountAction,
|
||||
error: this._barcodeErrorAction,
|
||||
gs1: this._barcodeGS1Action,
|
||||
});
|
||||
NumberBuffer.use({
|
||||
nonKeyboardInputEvent: 'numpad-click-input',
|
||||
triggerAtInput: 'update-selected-orderline',
|
||||
useWithBarcode: true,
|
||||
});
|
||||
onMounted(this.onMounted);
|
||||
// Call `reset` when the `onMounted` callback in `NumberBuffer.use` is done.
|
||||
// We don't do this in the `mounted` lifecycle method because it is called before
|
||||
// the callbacks in `onMounted` hook.
|
||||
onMounted(() => NumberBuffer.reset());
|
||||
this.state = useState({
|
||||
mobile_pane: this.props.mobile_pane || 'right',
|
||||
});
|
||||
}
|
||||
onMounted() {
|
||||
this.env.posbus.trigger('start-cash-control');
|
||||
}
|
||||
/**
|
||||
* To be overridden by modules that checks availability of
|
||||
* connected scale.
|
||||
* @see _onScaleNotAvailable
|
||||
*/
|
||||
get isScaleAvailable() {
|
||||
return true;
|
||||
}
|
||||
get partner() {
|
||||
return this.currentOrder ? this.currentOrder.get_partner() : null;
|
||||
}
|
||||
get currentOrder() {
|
||||
return this.env.pos.get_order();
|
||||
}
|
||||
async _getAddProductOptions(product, code) {
|
||||
let price_extra = 0.0;
|
||||
let draftPackLotLines, weight, description, packLotLinesToEdit;
|
||||
|
||||
if (_.some(product.attribute_line_ids, (id) => id in this.env.pos.attributes_by_ptal_id)) {
|
||||
let { confirmed, payload } = await this._openProductConfiguratorPopup(product);
|
||||
if (confirmed) {
|
||||
description = payload.selected_attributes.join(', ');
|
||||
price_extra += payload.price_extra;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gather lot information if required.
|
||||
if (['serial', 'lot'].includes(product.tracking) && (this.env.pos.picking_type.use_create_lots || this.env.pos.picking_type.use_existing_lots)) {
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
// if the lot information exists in the barcode, we don't need to ask it from the user.
|
||||
if (code && code.type === 'lot') {
|
||||
// consider the old and new packlot lines
|
||||
const modifiedPackLotLines = Object.fromEntries(
|
||||
packLotLinesToEdit.filter(item => item.id).map(item => [item.id, item.text])
|
||||
);
|
||||
const newPackLotLines = [
|
||||
{ lot_name: code.code },
|
||||
];
|
||||
draftPackLotLines = { modifiedPackLotLines, newPackLotLines };
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
if (code && this.env.pos.db.product_packaging_by_barcode[code.code]) {
|
||||
weight = this.env.pos.db.product_packaging_by_barcode[code.code].qty;
|
||||
}
|
||||
|
||||
return { draftPackLotLines, quantity: weight, description, price_extra };
|
||||
}
|
||||
async _openProductConfiguratorPopup(product) {
|
||||
let attributes = _.map(product.attribute_line_ids, (id) => this.env.pos.attributes_by_ptal_id[id])
|
||||
.filter((attr) => attr !== undefined);
|
||||
|
||||
// avoid opening the popup when each attribute has only one available option.
|
||||
if (_.some(attributes, (attribute) => attribute.values.length > 1 || _.some(attribute.values, (value) => value.is_custom))) {
|
||||
return await this.showPopup('ProductConfiguratorPopup', {
|
||||
product: product,
|
||||
attributes: attributes,
|
||||
});
|
||||
};
|
||||
|
||||
let selected_attributes = [];
|
||||
let price_extra = 0.0;
|
||||
|
||||
attributes.forEach((attribute) => {
|
||||
selected_attributes.push(attribute.values[0].name);
|
||||
price_extra += attribute.values[0].price_extra;
|
||||
});
|
||||
|
||||
return {
|
||||
confirmed: true,
|
||||
payload: {
|
||||
selected_attributes,
|
||||
price_extra,
|
||||
}
|
||||
};
|
||||
}
|
||||
async _addProduct(product, options) {
|
||||
this.currentOrder.add_product(product, options);
|
||||
}
|
||||
async _clickProduct(event) {
|
||||
if (!this.currentOrder) {
|
||||
this.env.pos.add_new_order();
|
||||
}
|
||||
const product = event.detail.product || event.detail;
|
||||
const options = await this._getAddProductOptions(product);
|
||||
// Do not add product if options is undefined.
|
||||
if (!options) return;
|
||||
// Update the quantity if the event has a quantity.
|
||||
if (event.detail.quantity !== undefined) {
|
||||
options.quantity = event.detail.quantity;
|
||||
}
|
||||
// Add the product after having the extra information.
|
||||
await this._addProduct(product, options);
|
||||
NumberBuffer.reset();
|
||||
}
|
||||
_setNumpadMode(event) {
|
||||
const { mode } = event.detail;
|
||||
NumberBuffer.capture();
|
||||
NumberBuffer.reset();
|
||||
this.env.pos.numpadMode = mode;
|
||||
}
|
||||
_selectLine() {
|
||||
NumberBuffer.reset();
|
||||
}
|
||||
async _updateSelectedOrderline(event) {
|
||||
const order = this.env.pos.get_order();
|
||||
const selectedLine = order.get_selected_orderline();
|
||||
// This validation must not be affected by `disallowLineQuantityChange`
|
||||
if (selectedLine && selectedLine.isTipLine() && this.env.pos.numpadMode !== "price") {
|
||||
/**
|
||||
* You can actually type numbers from your keyboard, while a popup is shown, causing
|
||||
* the number buffer storage to be filled up with the data typed. So we force the
|
||||
* clean-up of that buffer whenever we detect this illegal action.
|
||||
*/
|
||||
NumberBuffer.reset();
|
||||
if (event.detail.key === "Backspace") {
|
||||
this._setValue("remove");
|
||||
} else {
|
||||
this.showPopup("ErrorPopup", {
|
||||
title: this.env._t("Cannot modify a tip"),
|
||||
body: this.env._t("Customer tips, cannot be modified directly"),
|
||||
});
|
||||
}
|
||||
} else if (this.env.pos.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) {
|
||||
if(!order.orderlines.length)
|
||||
return;
|
||||
let orderlines = order.orderlines;
|
||||
let lastId = orderlines.length !== 0 && orderlines.at(orderlines.length - 1).cid;
|
||||
let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity();
|
||||
|
||||
if(selectedLine.noDecrease) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Invalid action'),
|
||||
body: this.env._t('You are not allowed to change this quantity'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parsedInput = event.detail.buffer && parse.float(event.detail.buffer) || 0;
|
||||
if(lastId != selectedLine.cid)
|
||||
this._showDecreaseQuantityPopup();
|
||||
else if(currentQuantity < parsedInput)
|
||||
this._setValue(event.detail.buffer);
|
||||
else if(parsedInput < currentQuantity)
|
||||
this._showDecreaseQuantityPopup();
|
||||
} else {
|
||||
let { buffer } = event.detail;
|
||||
let val = buffer === null ? 'remove' : buffer;
|
||||
this._setValue(val);
|
||||
if (val == 'remove') {
|
||||
NumberBuffer.reset();
|
||||
this.env.pos.numpadMode = 'quantity';
|
||||
}
|
||||
}
|
||||
}
|
||||
_setValue(val) {
|
||||
if (this.currentOrder.get_selected_orderline()) {
|
||||
if (this.env.pos.numpadMode === 'quantity') {
|
||||
const result = this.currentOrder.get_selected_orderline().set_quantity(val);
|
||||
if (!result) NumberBuffer.reset();
|
||||
} else if (this.env.pos.numpadMode === 'discount') {
|
||||
this.currentOrder.get_selected_orderline().set_discount(val);
|
||||
} else if (this.env.pos.numpadMode === 'price') {
|
||||
var selected_orderline = this.currentOrder.get_selected_orderline();
|
||||
selected_orderline.price_manually_set = true;
|
||||
selected_orderline.set_unit_price(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
async _getProductByBarcode(code) {
|
||||
let product = this.env.pos.db.get_product_by_barcode(code.base_code);
|
||||
if (!product) {
|
||||
// find the barcode in the backend
|
||||
let foundProductIds = [];
|
||||
const foundPackagings = [];
|
||||
try {
|
||||
const { product_id = [], packaging = [] } = await this.rpc({
|
||||
model: 'pos.session',
|
||||
method: 'find_product_by_barcode',
|
||||
args: [odoo.pos_session_id, code.base_code],
|
||||
context: this.env.session.user_context,
|
||||
});
|
||||
foundProductIds.push(...product_id);
|
||||
foundPackagings.push(...packaging);
|
||||
} catch (error) {
|
||||
if (isConnectionError(error)) {
|
||||
return this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t("Product is not loaded. Tried loading the product from the server but there is a network error."),
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (foundProductIds.length) {
|
||||
await this.env.pos._addProducts(foundProductIds, false);
|
||||
if (foundPackagings.length) {
|
||||
this.env.pos.db.add_packagings(foundPackagings);
|
||||
}
|
||||
// assume that the result is unique.
|
||||
product = this.env.pos.db.get_product_by_id(foundProductIds[0]);
|
||||
} else {
|
||||
return this._barcodeErrorAction(code);
|
||||
}
|
||||
}
|
||||
return product
|
||||
}
|
||||
async _barcodeProductAction(code) {
|
||||
const product = await this._getProductByBarcode(code);
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
const options = await this._getAddProductOptions(product, code);
|
||||
// Do not proceed on adding the product when no options is returned.
|
||||
// This is consistent with _clickProduct.
|
||||
if (!options) return;
|
||||
|
||||
// update the options depending on the type of the scanned code
|
||||
if (code.type === 'price') {
|
||||
Object.assign(options, {
|
||||
price: code.value,
|
||||
extras: {
|
||||
price_manually_set: true,
|
||||
},
|
||||
});
|
||||
} else if (code.type === 'weight' || code.type === 'quantity') {
|
||||
Object.assign(options, {
|
||||
quantity: code.value,
|
||||
merge: false,
|
||||
});
|
||||
} else if (code.type === 'discount') {
|
||||
Object.assign(options, {
|
||||
discount: code.value,
|
||||
merge: false,
|
||||
});
|
||||
}
|
||||
this.currentOrder.add_product(product, options);
|
||||
NumberBuffer.reset();
|
||||
}
|
||||
_barcodePartnerAction(code) {
|
||||
const partner = this.env.pos.db.get_partner_by_barcode(code.code);
|
||||
if (partner) {
|
||||
if (this.currentOrder.get_partner() !== partner) {
|
||||
this.currentOrder.set_partner(partner);
|
||||
this.currentOrder.updatePricelist(partner);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
async _parseElementsFromGS1(parsed_results) {
|
||||
const productBarcode = parsed_results.find(element => element.type === 'product');
|
||||
const lotBarcode = parsed_results.find(element => element.type === 'lot');
|
||||
const product = await this._getProductByBarcode(productBarcode);
|
||||
return { product, lotBarcode, customProductOptions: {} }
|
||||
}
|
||||
/**
|
||||
* Add a product to the current order using the product identifier and lot number from parsed results.
|
||||
* This function retrieves the product identifier and lot number from the `parsed_results` parameter.
|
||||
* It then uses these values to retrieve the product and add it to the current order.
|
||||
*/
|
||||
async _barcodeGS1Action(parsed_results) {
|
||||
const { product, lotBarcode, customProductOptions } = await this._parseElementsFromGS1(parsed_results)
|
||||
if (!product) {
|
||||
return;
|
||||
}
|
||||
const options = await this._getAddProductOptions(product, lotBarcode);
|
||||
await this.currentOrder.add_product(product, { ...options, ...customProductOptions });
|
||||
NumberBuffer.reset();
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
async _displayAllControlPopup() {
|
||||
await this.showPopup('ControlButtonPopup', {
|
||||
controlButtons: this.controlButtons
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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('Set the new quantity'),
|
||||
});
|
||||
let newQuantity = inputNumber && inputNumber !== "" ? parse.float(inputNumber) : null;
|
||||
if (confirmed && newQuantity !== null) {
|
||||
let order = this.env.pos.get_order();
|
||||
let selectedLine = this.env.pos.get_order().get_selected_orderline();
|
||||
let currentQuantity = selectedLine.get_quantity()
|
||||
if(selectedLine.is_last_line() && currentQuantity === 1 && newQuantity < currentQuantity)
|
||||
selectedLine.set_quantity(newQuantity);
|
||||
else if(newQuantity >= currentQuantity)
|
||||
selectedLine.set_quantity(newQuantity);
|
||||
else {
|
||||
let newLine = selectedLine.clone();
|
||||
let decreasedQuantity = currentQuantity - newQuantity
|
||||
newLine.order = order;
|
||||
|
||||
newLine.set_quantity( - decreasedQuantity, true);
|
||||
order.add_orderline(newLine);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async onClickPartner() {
|
||||
// IMPROVEMENT: This code snippet is very similar to selectPartner of PaymentScreen.
|
||||
const currentPartner = this.currentOrder.get_partner();
|
||||
if (currentPartner && this.currentOrder.getHasRefundLines()) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t("Can't change customer"),
|
||||
body: _.str.sprintf(
|
||||
this.env._t(
|
||||
"This order already has refund lines for %s. We can't change the customer associated to it. Create a new order for the new customer."
|
||||
),
|
||||
currentPartner.name
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { confirmed, payload: newPartner } = await this.showTempScreen(
|
||||
'PartnerListScreen',
|
||||
{ partner: currentPartner }
|
||||
);
|
||||
if (confirmed) {
|
||||
this.currentOrder.set_partner(newPartner);
|
||||
this.currentOrder.updatePricelist(newPartner);
|
||||
}
|
||||
}
|
||||
async _onClickPay() {
|
||||
if (this.env.pos.get_order().orderlines.some(line => line.get_product().tracking !== 'none' && !line.has_valid_product_lot()) && (this.env.pos.picking_type.use_create_lots || this.env.pos.picking_type.use_existing_lots)) {
|
||||
const { confirmed } = await this.showPopup('ConfirmPopup', {
|
||||
title: this.env._t('Some Serial/Lot Numbers are missing'),
|
||||
body: this.env._t('You are trying to sell products with serial/lot numbers, but some of them are not set.\nWould you like to proceed anyway?'),
|
||||
confirmText: this.env._t('Yes'),
|
||||
cancelText: this.env._t('No')
|
||||
});
|
||||
if (confirmed) {
|
||||
this.showScreen('PaymentScreen');
|
||||
}
|
||||
} else {
|
||||
this.showScreen('PaymentScreen');
|
||||
}
|
||||
}
|
||||
switchPane() {
|
||||
this.state.mobile_pane = this.state.mobile_pane === "left" ? "right" : "left";
|
||||
}
|
||||
}
|
||||
ProductScreen.template = 'ProductScreen';
|
||||
ProductScreen.numpadActionName = _lt('Payment');
|
||||
|
||||
Registries.Component.add(ProductScreen);
|
||||
|
||||
return ProductScreen;
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
odoo.define('point_of_sale.ProductsWidget', function(require) {
|
||||
'use strict';
|
||||
|
||||
const { identifyError } = require('point_of_sale.utils');
|
||||
const { ConnectionLostError, ConnectionAbortedError } = require('@web/core/network/rpc_service');
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { onWillUnmount, useState } = owl;
|
||||
|
||||
class ProductsWidget extends PosComponent {
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {number?} props.startCategoryId
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('switch-category', this._switchCategory);
|
||||
useListener('update-search', this._updateSearch);
|
||||
useListener('clear-search', this._clearSearch);
|
||||
useListener('load-products-from-server', this._onPressEnterKey);
|
||||
this.state = useState({ searchWord: '', previousSearchWord: "", currentOffset: 0 });
|
||||
onWillUnmount(this.onWillUnmount);
|
||||
}
|
||||
onWillUnmount() {
|
||||
this.trigger('toggle-mobile-searchbar', false);
|
||||
}
|
||||
get selectedCategoryId() {
|
||||
return this.env.pos.selectedCategoryId;
|
||||
}
|
||||
get searchWord() {
|
||||
return this.state.searchWord.trim();
|
||||
}
|
||||
get productsToDisplay() {
|
||||
let list = [];
|
||||
if (this.searchWord !== '') {
|
||||
list = this.env.pos.db.search_product_in_category(
|
||||
this.selectedCategoryId,
|
||||
this.searchWord
|
||||
);
|
||||
} else {
|
||||
list = this.env.pos.db.get_product_by_category(this.selectedCategoryId);
|
||||
}
|
||||
return list.sort(function (a, b) { return a.display_name.localeCompare(b.display_name) });
|
||||
}
|
||||
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));
|
||||
}
|
||||
get hasNoCategories() {
|
||||
return this.env.pos.db.get_category_childs_ids(0).length === 0;
|
||||
}
|
||||
get shouldShowButton() {
|
||||
return this.productsToDisplay.length === 0 && this.searchWord;
|
||||
}
|
||||
_switchCategory(event) {
|
||||
this.env.pos.setSelectedCategoryId(event.detail);
|
||||
}
|
||||
_updateSearch(event) {
|
||||
this.state.searchWord = event.detail;
|
||||
}
|
||||
_clearSearch() {
|
||||
this.state.searchWord = '';
|
||||
}
|
||||
_updateProductList(event) {
|
||||
this.render(true);
|
||||
this.trigger('switch-category', 0);
|
||||
}
|
||||
async _onPressEnterKey() {
|
||||
if (!this.state.searchWord) return;
|
||||
if (this.state.previousSearchWord != this.state.searchWord) {
|
||||
this.state.currentOffset = 0;
|
||||
}
|
||||
const result = await this.loadProductFromDB();
|
||||
if (result.length > 0) {
|
||||
this.showNotification(
|
||||
_.str.sprintf(
|
||||
this.env._t('%s product(s) found for "%s".'),
|
||||
result.length,
|
||||
this.state.searchWord
|
||||
),
|
||||
3000
|
||||
);
|
||||
} else {
|
||||
this.showNotification(
|
||||
_.str.sprintf(
|
||||
this.env._t('No more product found for "%s".'),
|
||||
this.state.searchWord
|
||||
),
|
||||
3000
|
||||
);
|
||||
}
|
||||
if (this.state.previousSearchWord == this.state.searchWord) {
|
||||
this.state.currentOffset += result.length;
|
||||
} else {
|
||||
this.state.previousSearchWord = this.state.searchWord;
|
||||
this.state.currentOffset = result.length;
|
||||
}
|
||||
}
|
||||
async loadProductFromDB() {
|
||||
if(!this.state.searchWord)
|
||||
return;
|
||||
|
||||
try {
|
||||
const limit = 30;
|
||||
let ProductIds = await this.rpc({
|
||||
model: 'product.product',
|
||||
method: 'search',
|
||||
args: [['&',['available_in_pos', '=', true], '|','|',
|
||||
['name', 'ilike', this.state.searchWord],
|
||||
['default_code', 'ilike', this.state.searchWord],
|
||||
['barcode', 'ilike', this.state.searchWord]]],
|
||||
context: this.env.session.user_context,
|
||||
kwargs: {
|
||||
offset: this.state.currentOffset,
|
||||
limit: limit,
|
||||
}
|
||||
});
|
||||
if(ProductIds.length) {
|
||||
await this.env.pos._addProducts(ProductIds, false);
|
||||
}
|
||||
this._updateProductList();
|
||||
return ProductIds;
|
||||
} catch (error) {
|
||||
const identifiedError = identifyError(error)
|
||||
if (identifiedError instanceof ConnectionLostError || identifiedError instanceof ConnectionAbortedError) {
|
||||
return this.showPopup('OfflineErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t("Product is not loaded. Tried loading the product from the server but there is a network error."),
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProductsWidget.template = 'ProductsWidget';
|
||||
|
||||
Registries.Component.add(ProductsWidget);
|
||||
|
||||
return ProductsWidget;
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { debounce } = require("@web/core/utils/timing");
|
||||
|
||||
const { onMounted, onWillUnmount, useRef } = owl;
|
||||
|
||||
class ProductsWidgetControlPanel extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.searchWordInput = useRef('search-word-input-product');
|
||||
this.updateSearch = debounce(this.updateSearch, 100);
|
||||
|
||||
onMounted(() => {
|
||||
this.env.posbus.on('search-product-from-info-popup', this, this.searchProductFromInfo)
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.env.posbus.off('search-product-from-info-popup', this);
|
||||
});
|
||||
}
|
||||
_clearSearch() {
|
||||
this.searchWordInput.el.value = '';
|
||||
this.trigger('clear-search');
|
||||
}
|
||||
get displayCategImages() {
|
||||
return Object.values(this.env.pos.db.category_by_id).some(categ => categ.has_image) && !this.env.isMobile;
|
||||
}
|
||||
updateSearch(event) {
|
||||
this.trigger('update-search', event.target.value);
|
||||
}
|
||||
async _onPressEnterKey() {
|
||||
if (!this.searchWordInput.el.value) return;
|
||||
this.trigger('load-products-from-server');
|
||||
}
|
||||
searchProductFromInfo(productName) {
|
||||
this.searchWordInput.el.value = productName;
|
||||
this.trigger('switch-category', 0);
|
||||
this.trigger('update-search', productName);
|
||||
}
|
||||
_toggleMobileSearchbar() {
|
||||
this.trigger('toggle-mobile-searchbar');
|
||||
}
|
||||
}
|
||||
ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel';
|
||||
|
||||
Registries.Component.add(ProductsWidgetControlPanel);
|
||||
|
||||
return ProductsWidgetControlPanel;
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
odoo.define('point_of_sale.OrderReceipt', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { onWillUpdateProps } = owl;
|
||||
|
||||
class OrderReceipt extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
this._receiptEnv = this.props.order.getOrderReceiptEnv();
|
||||
|
||||
onWillUpdateProps((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.is_in_unit &&
|
||||
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,161 @@
|
|||
odoo.define('point_of_sale.ReceiptScreen', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { Printer } = require('point_of_sale.Printer');
|
||||
const { is_email } = require('web.utils');
|
||||
const { useErrorHandlers } = require('point_of_sale.custom_hooks');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
|
||||
const { useAsyncLockedMethod } = require('point_of_sale.custom_hooks');
|
||||
|
||||
const { onMounted, useRef, status } = owl;
|
||||
|
||||
const ReceiptScreen = (AbstractReceiptScreen) => {
|
||||
class ReceiptScreen extends AbstractReceiptScreen {
|
||||
setup() {
|
||||
super.setup();
|
||||
useErrorHandlers();
|
||||
this.orderReceipt = useRef('order-receipt');
|
||||
const order = this.currentOrder;
|
||||
const partner = order.get_partner();
|
||||
this.orderUiState = order.uiState.ReceiptScreen;
|
||||
this.orderUiState.inputEmail = this.orderUiState.inputEmail || (partner && partner.email) || '';
|
||||
this.orderUiState.isSendingEmail = false;
|
||||
this.is_email = is_email;
|
||||
|
||||
onMounted(() => {
|
||||
// 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 () => {
|
||||
if (status(this) === "mounted") {
|
||||
let images = this.orderReceipt.el.getElementsByTagName('img');
|
||||
for (let image of images) {
|
||||
await image.decode();
|
||||
}
|
||||
await this.handleAutoPrint();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
this.onSendEmail = useAsyncLockedMethod(this.onSendEmail);
|
||||
}
|
||||
_addNewOrder() {
|
||||
this.env.pos.add_new_order();
|
||||
}
|
||||
async onSendEmail() {
|
||||
if (!is_email(this.orderUiState.inputEmail)) {
|
||||
this.orderUiState.emailSuccessful = false;
|
||||
this.orderUiState.emailNotice = this.env._t('Invalid email.');
|
||||
return;
|
||||
}
|
||||
let isSuccess = false;
|
||||
let notice = "";
|
||||
try {
|
||||
this.orderUiState.isSendingEmail = true;
|
||||
await this._sendReceiptToCustomer();
|
||||
isSuccess = true;
|
||||
notice = this.env._t("Email sent.");
|
||||
} catch (_error) {
|
||||
isSuccess = false;
|
||||
notice = this.env._t("Sending email failed. Please try again.");
|
||||
} finally {
|
||||
// Wait for 1 second before reflecting the state change in the UI.
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
this.orderUiState.emailSuccessful = isSuccess;
|
||||
this.orderUiState.emailNotice = notice;
|
||||
this.orderUiState.isSendingEmail = false;
|
||||
}
|
||||
}
|
||||
get orderAmountPlusTip() {
|
||||
const order = this.currentOrder;
|
||||
const orderTotalAmount = order.get_total_with_tax();
|
||||
const tip_product_id = this.env.pos.config.tip_product_id && this.env.pos.config.tip_product_id[0];
|
||||
const tipLine = order
|
||||
.get_orderlines()
|
||||
.find((line) => tip_product_id && line.product.id === tip_product_id);
|
||||
const tipAmount = tipLine ? tipLine.get_all_prices().priceWithTax : 0;
|
||||
const orderAmountStr = this.env.pos.format_currency(orderTotalAmount - tipAmount);
|
||||
if (!tipAmount) return orderAmountStr;
|
||||
const tipAmountStr = this.env.pos.format_currency(tipAmount);
|
||||
return `${orderAmountStr} + ${tipAmountStr} tip`;
|
||||
}
|
||||
get currentOrder() {
|
||||
return this.env.pos.get_order();
|
||||
}
|
||||
get nextScreen() {
|
||||
return { name: 'ProductScreen' };
|
||||
}
|
||||
whenClosing() {
|
||||
this.orderDone();
|
||||
}
|
||||
/**
|
||||
* 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()) {
|
||||
const currentOrder = this.currentOrder;
|
||||
await this.printReceipt();
|
||||
if (this.currentOrder && this.currentOrder === currentOrder && currentOrder._printed && this._shouldCloseImmediately()) {
|
||||
this.whenClosing();
|
||||
}
|
||||
}
|
||||
}
|
||||
orderDone() {
|
||||
this.env.pos.removeOrder(this.currentOrder);
|
||||
this._addNewOrder();
|
||||
const { name, props } = this.nextScreen;
|
||||
this.showScreen(name, props);
|
||||
if (this.env.pos.config.iface_customer_facing_display) {
|
||||
this.env.pos.send_current_order_to_customer_facing_display();
|
||||
}
|
||||
}
|
||||
async printReceipt() {
|
||||
const currentOrder = this.currentOrder;
|
||||
const isPrinted = await this._printReceipt();
|
||||
if (isPrinted) {
|
||||
currentOrder._printed = true;
|
||||
}
|
||||
}
|
||||
_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.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized;
|
||||
}
|
||||
async _sendReceiptToCustomer() {
|
||||
const printer = new Printer(null, this.env.pos);
|
||||
printer.isEmail = true;
|
||||
const receiptString = this.orderReceipt.el.innerHTML;
|
||||
const ticketImage = await printer.htmlToImg(receiptString);
|
||||
const order = this.currentOrder;
|
||||
const partner = order.get_partner();
|
||||
const orderName = order.get_name();
|
||||
const orderPartner = { email: this.orderUiState.inputEmail, name: partner ? partner.name : this.orderUiState.inputEmail };
|
||||
const order_server_id = this.env.pos.validated_orders_name_server_id_map[orderName];
|
||||
if (!order_server_id) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Unsynced order'),
|
||||
body: this.env._t('This order is not yet synced to server. Make sure it is synced then try again.'),
|
||||
});
|
||||
return Promise.reject();
|
||||
}
|
||||
await this.rpc({
|
||||
model: 'pos.order',
|
||||
method: 'action_receipt_to_customer',
|
||||
args: [[order_server_id], orderName, orderPartner, ticketImage],
|
||||
});
|
||||
}
|
||||
}
|
||||
ReceiptScreen.template = 'ReceiptScreen';
|
||||
return ReceiptScreen;
|
||||
};
|
||||
|
||||
Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen);
|
||||
|
||||
return ReceiptScreen;
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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 {}
|
||||
WrappedProductNameLines.template = 'WrappedProductNameLines';
|
||||
|
||||
Registries.Component.add(WrappedProductNameLines);
|
||||
|
||||
return WrappedProductNameLines;
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
odoo.define('point_of_sale.ScaleScreen', function(require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const { round_precision: round_pr } = require('web.utils');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const { onMounted, onWillUnmount, useExternalListener, useState } = owl;
|
||||
|
||||
class ScaleScreen extends PosComponent {
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.product The product to weight.
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
useExternalListener(document, 'keyup', this._onHotkeys);
|
||||
this.state = useState({ weight: 0 });
|
||||
onMounted(this.onMounted);
|
||||
onWillUnmount(this.onWillUnmount);
|
||||
}
|
||||
onMounted() {
|
||||
// start the scale reading
|
||||
this._readScale();
|
||||
}
|
||||
onWillUnmount() {
|
||||
// stop the scale reading
|
||||
this.env.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.proxy_queue.schedule(this._setWeight.bind(this), {
|
||||
duration: 500,
|
||||
repeat: true,
|
||||
});
|
||||
}
|
||||
async _setWeight() {
|
||||
const reading = await this.env.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;
|
||||
});
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
odoo.define('point_of_sale.InvoiceButton', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const { isConnectionError } = require('point_of_sale.utils');
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class InvoiceButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this._onClick);
|
||||
}
|
||||
get isAlreadyInvoiced() {
|
||||
if (!this.props.order) return false;
|
||||
return Boolean(this.props.order.account_move);
|
||||
}
|
||||
get commandName() {
|
||||
if (!this.props.order) {
|
||||
return this.env._t('Invoice');
|
||||
} else {
|
||||
return this.isAlreadyInvoiced
|
||||
? this.env._t('Reprint Invoice')
|
||||
: this.env._t('Invoice');
|
||||
}
|
||||
}
|
||||
async _downloadInvoice(orderId) {
|
||||
try {
|
||||
const [orderWithInvoice] = await this.rpc({
|
||||
method: 'read',
|
||||
model: 'pos.order',
|
||||
args: [orderId, ['account_move']],
|
||||
kwargs: { load: false },
|
||||
});
|
||||
if (orderWithInvoice && orderWithInvoice.account_move) {
|
||||
await this.env.legacyActionManager.do_action(this.env.pos.invoiceReportAction, {
|
||||
additional_context: {
|
||||
active_ids: [orderWithInvoice.account_move],
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
} else {
|
||||
// NOTE: error here is most probably undefined
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t('Unable to download invoice.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
async _invoiceOrder() {
|
||||
const order = this.props.order;
|
||||
if (!order) return;
|
||||
|
||||
const orderId = order.backendId;
|
||||
|
||||
// Part 0. If already invoiced, print the invoice.
|
||||
if (this.isAlreadyInvoiced) {
|
||||
await this._downloadInvoice(orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Part 1: Handle missing partner.
|
||||
// Write to pos.order the selected partner.
|
||||
if (!order.get_partner()) {
|
||||
const { confirmed: confirmedPopup } = await this.showPopup('ConfirmPopup', {
|
||||
title: this.env._t('Need customer to invoice'),
|
||||
body: this.env._t('Do you want to open the customer list to select customer?'),
|
||||
});
|
||||
if (!confirmedPopup) return;
|
||||
|
||||
const { confirmed: confirmedTempScreen, payload: newPartner } = await this.showTempScreen(
|
||||
'PartnerListScreen'
|
||||
);
|
||||
if (!confirmedTempScreen) return;
|
||||
|
||||
await this.rpc({
|
||||
model: 'pos.order',
|
||||
method: 'write',
|
||||
args: [[orderId], { partner_id: newPartner.id }],
|
||||
kwargs: { context: this.env.session.user_context },
|
||||
});
|
||||
}
|
||||
|
||||
// Part 2: Invoice the order.
|
||||
await this.rpc(
|
||||
{
|
||||
model: 'pos.order',
|
||||
method: 'action_pos_order_invoice',
|
||||
args: [orderId],
|
||||
kwargs: { context: this.env.session.user_context },
|
||||
},
|
||||
{
|
||||
timeout: 30000,
|
||||
shadow: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Part 3: Download invoice.
|
||||
await this._downloadInvoice(orderId);
|
||||
this.trigger('order-invoiced', orderId);
|
||||
}
|
||||
async _onClick() {
|
||||
try {
|
||||
this.el.style.pointerEvents = 'none';
|
||||
await this._invoiceOrder();
|
||||
} catch (error) {
|
||||
if (isConnectionError(error)) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Network Error'),
|
||||
body: this.env._t('Unable to invoice order.'),
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
this.el.style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
InvoiceButton.template = 'InvoiceButton';
|
||||
Registries.Component.add(InvoiceButton);
|
||||
|
||||
return InvoiceButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
odoo.define('point_of_sale.ReprintReceiptButton', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
class ReprintReceiptButton extends PosComponent {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('click', this._onClick);
|
||||
}
|
||||
async _onClick() {
|
||||
if (!this.props.order) return;
|
||||
this.showScreen('ReprintReceiptScreen', { order: this.props.order });
|
||||
}
|
||||
}
|
||||
ReprintReceiptButton.template = 'ReprintReceiptButton';
|
||||
Registries.Component.add(ReprintReceiptButton);
|
||||
|
||||
return ReprintReceiptButton;
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
odoo.define('point_of_sale.OrderDetails', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
/**
|
||||
* @props {models.Order} order
|
||||
*/
|
||||
class OrderDetails extends PosComponent {
|
||||
get order() {
|
||||
return this.props.order;
|
||||
}
|
||||
get orderlines() {
|
||||
return this.order ? this.order.orderlines : [];
|
||||
}
|
||||
get total() {
|
||||
return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0);
|
||||
}
|
||||
get tax() {
|
||||
return this.env.pos.format_currency(this.order ? this.order.get_total_tax() : 0)
|
||||
}
|
||||
}
|
||||
OrderDetails.template = 'OrderDetails';
|
||||
|
||||
Registries.Component.add(OrderDetails);
|
||||
|
||||
return OrderDetails;
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
odoo.define('point_of_sale.OrderlineDetails', function (require) {
|
||||
'use strict';
|
||||
|
||||
const PosComponent = require('point_of_sale.PosComponent');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const { format } = require('web.field_utils');
|
||||
const { round_precision: round_pr } = require('web.utils');
|
||||
|
||||
/**
|
||||
* @props {pos.order.line} line
|
||||
*/
|
||||
class OrderlineDetails extends PosComponent {
|
||||
get line() {
|
||||
const line = this.props.line;
|
||||
const formatQty = (line) => {
|
||||
const quantity = line.get_quantity();
|
||||
const unit = line.get_unit();
|
||||
const decimals = this.env.pos.dp['Product Unit of Measure'];
|
||||
const rounding = Math.max(unit.rounding, Math.pow(10, -decimals));
|
||||
const roundedQuantity = round_pr(quantity, rounding);
|
||||
return format.float(roundedQuantity, { digits: [69, decimals] });
|
||||
};
|
||||
return {
|
||||
productName: line.get_full_product_name(),
|
||||
totalPrice: line.get_price_with_tax(),
|
||||
quantity: formatQty(line),
|
||||
unit: line.get_unit().name,
|
||||
unitPrice: line.get_unit_price(),
|
||||
};
|
||||
}
|
||||
get productName() {
|
||||
return this.line.productName;
|
||||
}
|
||||
get totalPrice() {
|
||||
return this.env.pos.format_currency(this.line.totalPrice);
|
||||
}
|
||||
get quantity() {
|
||||
return this.line.quantity;
|
||||
}
|
||||
get unitPrice() {
|
||||
return this.env.pos.format_currency(this.line.unitPrice);
|
||||
}
|
||||
get unit() {
|
||||
return this.line.unit;
|
||||
}
|
||||
get pricePerUnit() {
|
||||
return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`;
|
||||
}
|
||||
get customerNote() {
|
||||
return this.props.line.get_customer_note();
|
||||
}
|
||||
getToRefundDetail() {
|
||||
return this.env.pos.toRefundLines[this.props.line.id];
|
||||
}
|
||||
hasRefundedQty() {
|
||||
return !this.env.pos.isProductQtyZero(this.props.line.refunded_qty);
|
||||
}
|
||||
getFormattedRefundedQty() {
|
||||
return this.env.pos.formatProductQty(this.props.line.refunded_qty);
|
||||
}
|
||||
hasToRefundQty() {
|
||||
const toRefundDetail = this.getToRefundDetail();
|
||||
return !this.env.pos.isProductQtyZero(toRefundDetail && toRefundDetail.qty);
|
||||
}
|
||||
getFormattedToRefundQty() {
|
||||
const toRefundDetail = this.getToRefundDetail();
|
||||
return this.env.pos.formatProductQty(toRefundDetail && toRefundDetail.qty);
|
||||
}
|
||||
getRefundingMessage() {
|
||||
return _.str.sprintf(this.env._t('Refunding %s in '), this.getFormattedToRefundQty());
|
||||
}
|
||||
getToRefundMessage() {
|
||||
return _.str.sprintf(this.env._t('To Refund: %s'), this.getFormattedToRefundQty());
|
||||
}
|
||||
}
|
||||
OrderlineDetails.template = 'OrderlineDetails';
|
||||
|
||||
Registries.Component.add(OrderlineDetails);
|
||||
|
||||
return OrderlineDetails;
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
odoo.define('point_of_sale.ReprintReceiptScreen', function (require) {
|
||||
'use strict';
|
||||
|
||||
const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
|
||||
const ReprintReceiptScreen = (AbstractReceiptScreen) => {
|
||||
class ReprintReceiptScreen extends AbstractReceiptScreen {
|
||||
setup() {
|
||||
super.setup();
|
||||
owl.onMounted(this.onMounted);
|
||||
}
|
||||
onMounted() {
|
||||
setTimeout(() => {
|
||||
this.printReceipt();
|
||||
}, 50);
|
||||
}
|
||||
confirm() {
|
||||
this.showScreen('TicketScreen', { reuseSavedUIState: true });
|
||||
}
|
||||
async printReceipt() {
|
||||
if(this.env.proxy.printer && this.env.pos.config.iface_print_skip_screen) {
|
||||
let result = await this._printReceipt();
|
||||
if(result)
|
||||
this.showScreen('TicketScreen', { reuseSavedUIState: true });
|
||||
}
|
||||
}
|
||||
async tryReprint() {
|
||||
await this._printReceipt();
|
||||
}
|
||||
}
|
||||
ReprintReceiptScreen.template = 'ReprintReceiptScreen';
|
||||
return ReprintReceiptScreen;
|
||||
};
|
||||
Registries.Component.addByExtending(ReprintReceiptScreen, AbstractReceiptScreen);
|
||||
|
||||
return ReprintReceiptScreen;
|
||||
});
|
||||
|
|
@ -0,0 +1,696 @@
|
|||
odoo.define('point_of_sale.TicketScreen', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { Order } = require('point_of_sale.models');
|
||||
const Registries = require('point_of_sale.Registries');
|
||||
const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
|
||||
const NumberBuffer = require('point_of_sale.NumberBuffer');
|
||||
const { useListener } = require("@web/core/utils/hooks");
|
||||
const { parse } = require('web.field_utils');
|
||||
const { _lt } = require('@web/core/l10n/translation');
|
||||
|
||||
const { onMounted, onWillUnmount, useState } = owl;
|
||||
|
||||
class TicketScreen extends IndependentToOrderScreen {
|
||||
setup() {
|
||||
super.setup();
|
||||
useListener('close-screen', this._onCloseScreen);
|
||||
useListener('filter-selected', this._onFilterSelected);
|
||||
useListener('search', this._onSearch);
|
||||
useListener('click-order', this._onClickOrder);
|
||||
useListener('create-new-order', this._onCreateNewOrder);
|
||||
useListener('delete-order', this._onDeleteOrder);
|
||||
useListener('next-page', this._onNextPage);
|
||||
useListener('prev-page', this._onPrevPage);
|
||||
useListener('order-invoiced', this._onInvoiceOrder);
|
||||
useListener('click-order-line', this._onClickOrderline);
|
||||
useListener('click-refund-order-uid', this._onClickRefundOrderUid);
|
||||
useListener('update-selected-orderline', this._onUpdateSelectedOrderline);
|
||||
useListener('do-refund', this._onDoRefund);
|
||||
NumberBuffer.use({
|
||||
nonKeyboardInputEvent: 'numpad-click-input',
|
||||
triggerAtInput: 'update-selected-orderline',
|
||||
});
|
||||
this._state = this.env.pos.TICKET_SCREEN_STATE;
|
||||
this.state = useState({
|
||||
showSearchBar: !this.env.isMobile,
|
||||
});
|
||||
const defaultUIState = this.props.reuseSavedUIState
|
||||
? this._state.ui
|
||||
: {
|
||||
selectedSyncedOrderId: null,
|
||||
searchDetails: this.env.pos.getDefaultSearchDetails(),
|
||||
filter: null,
|
||||
selectedOrderlineIds: {},
|
||||
};
|
||||
Object.assign(this._state.ui, defaultUIState, this.props.ui || {});
|
||||
|
||||
onMounted(this.onMounted);
|
||||
onWillUnmount(this.onWillUnmount);
|
||||
}
|
||||
//#region LIFECYCLE METHODS
|
||||
onMounted() {
|
||||
this.env.posbus.on('ticket-button-clicked', this, this.close);
|
||||
setTimeout(() => {
|
||||
// Show updated list of synced orders when going back to the screen.
|
||||
this._onFilterSelected({ detail: { filter: this._state.ui.filter } });
|
||||
});
|
||||
}
|
||||
onWillUnmount() {
|
||||
this.env.posbus.off('ticket-button-clicked', this);
|
||||
}
|
||||
//#endregion
|
||||
//#region EVENT HANDLERS
|
||||
_onCloseScreen() {
|
||||
this.close();
|
||||
}
|
||||
async _onFilterSelected(event) {
|
||||
this._state.ui.filter = event.detail.filter;
|
||||
if (this._state.ui.filter == 'SYNCED') {
|
||||
await this._fetchSyncedOrders();
|
||||
}
|
||||
}
|
||||
async _onSearch(event) {
|
||||
Object.assign(this._state.ui.searchDetails, event.detail);
|
||||
if (this._state.ui.filter == 'SYNCED') {
|
||||
this._state.syncedOrders.currentPage = 1;
|
||||
await this._fetchSyncedOrders();
|
||||
}
|
||||
}
|
||||
_onClickOrder({ detail: clickedOrder }) {
|
||||
if (!clickedOrder || clickedOrder.locked) {
|
||||
if (this._state.ui.selectedSyncedOrderId == clickedOrder.backendId) {
|
||||
this._state.ui.selectedSyncedOrderId = null;
|
||||
} else {
|
||||
this._state.ui.selectedSyncedOrderId = clickedOrder.backendId;
|
||||
}
|
||||
if (!this.getSelectedOrderlineId()) {
|
||||
// Automatically select the first orderline of the selected order.
|
||||
const firstLine = clickedOrder.get_orderlines()[0];
|
||||
if (firstLine) {
|
||||
this._state.ui.selectedOrderlineIds[clickedOrder.backendId] = firstLine.id;
|
||||
}
|
||||
}
|
||||
NumberBuffer.reset();
|
||||
} else {
|
||||
this._setOrder(clickedOrder);
|
||||
}
|
||||
}
|
||||
_onCreateNewOrder() {
|
||||
this.env.pos.add_new_order();
|
||||
this.showScreen('ProductScreen');
|
||||
}
|
||||
_selectNextOrder(currentOrder) {
|
||||
const currentOrderIndex = this._getOrderList().indexOf(currentOrder);
|
||||
const orderList = this._getOrderList();
|
||||
this.env.pos.set_order(orderList[currentOrderIndex+1] || orderList[currentOrderIndex-1]);
|
||||
}
|
||||
async _onDeleteOrder({ detail: order }) {
|
||||
const screen = order.get_screen_data();
|
||||
if (['ProductScreen', 'PaymentScreen'].includes(screen.name) && order.get_orderlines().length > 0) {
|
||||
const { confirmed } = await this.showPopup('ConfirmPopup', {
|
||||
title: this.env._t('Existing orderlines'),
|
||||
body: _.str.sprintf(
|
||||
this.env._t('%s has a total amount of %s, are you sure you want to delete this order ?'),
|
||||
order.name, this.getTotal(order)
|
||||
),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
if (order && (await this._onBeforeDeleteOrder(order))) {
|
||||
if (order === this.env.pos.get_order()) {
|
||||
this._selectNextOrder(order);
|
||||
}
|
||||
this.env.pos.removeOrder(order);
|
||||
}
|
||||
}
|
||||
async _onNextPage() {
|
||||
if (this._state.syncedOrders.currentPage < this._getLastPage()) {
|
||||
this._state.syncedOrders.currentPage += 1;
|
||||
await this._fetchSyncedOrders();
|
||||
}
|
||||
}
|
||||
async _onPrevPage() {
|
||||
if (this._state.syncedOrders.currentPage > 1) {
|
||||
this._state.syncedOrders.currentPage -= 1;
|
||||
await this._fetchSyncedOrders();
|
||||
}
|
||||
}
|
||||
async _onInvoiceOrder({ detail: orderId }) {
|
||||
this.env.pos._invalidateSyncedOrdersCache([orderId]);
|
||||
await this._fetchSyncedOrders();
|
||||
}
|
||||
_onClickOrderline({ detail: orderline }) {
|
||||
const order = this.getSelectedSyncedOrder();
|
||||
this._state.ui.selectedOrderlineIds[order.backendId] = orderline.id;
|
||||
NumberBuffer.reset();
|
||||
}
|
||||
_onClickRefundOrderUid({ detail: orderUid }) {
|
||||
// Open the refund order.
|
||||
const refundOrder = this.env.pos.orders.find((order) => order.uid == orderUid);
|
||||
if (refundOrder) {
|
||||
this._setOrder(refundOrder);
|
||||
}
|
||||
}
|
||||
_onUpdateSelectedOrderline({ detail }) {
|
||||
const buffer = detail.buffer;
|
||||
const order = this.getSelectedSyncedOrder();
|
||||
if (!order) return NumberBuffer.reset();
|
||||
|
||||
const selectedOrderlineId = this.getSelectedOrderlineId();
|
||||
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
|
||||
if (!orderline) return NumberBuffer.reset();
|
||||
|
||||
const toRefundDetail = this._getToRefundDetail(orderline);
|
||||
// When already linked to an order, do not modify the to refund quantity.
|
||||
if (toRefundDetail.destinationOrderUid) return NumberBuffer.reset();
|
||||
|
||||
const refundableQty = toRefundDetail.orderline.qty - toRefundDetail.orderline.refundedQty;
|
||||
if (refundableQty <= 0) return NumberBuffer.reset();
|
||||
|
||||
if (buffer == null || buffer == '') {
|
||||
toRefundDetail.qty = 0;
|
||||
} else {
|
||||
const quantity = Math.abs(parse.float(buffer));
|
||||
if (quantity > refundableQty) {
|
||||
NumberBuffer.reset();
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Maximum Exceeded'),
|
||||
body: _.str.sprintf(
|
||||
this.env._t(
|
||||
'The requested quantity to be refunded is higher than the ordered quantity. %s is requested while only %s can be refunded.'
|
||||
),
|
||||
quantity,
|
||||
refundableQty
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toRefundDetail.qty = quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
async _onDoRefund() {
|
||||
const order = this.getSelectedSyncedOrder();
|
||||
|
||||
if (!order) {
|
||||
this._state.ui.highlightHeaderNote = !this._state.ui.highlightHeaderNote;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._doesOrderHaveSoleItem(order)) {
|
||||
if (!this._prepareAutoRefundOnOrder(order)) {
|
||||
// Don't proceed on refund if preparation returned false.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const partner = order.get_partner();
|
||||
|
||||
const allToRefundDetails = this._getRefundableDetails(partner);
|
||||
if (allToRefundDetails.length == 0) {
|
||||
this._state.ui.highlightHeaderNote = !this._state.ui.highlightHeaderNote;
|
||||
return;
|
||||
}
|
||||
|
||||
const orderIds = new Set(
|
||||
allToRefundDetails
|
||||
.map((detail) => detail.orderline.orderBackendId)
|
||||
);
|
||||
|
||||
if (orderIds.size > 1) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Multiple Orders Selected'),
|
||||
body: this.env._t('You have selected orderlines from multiple orders. To proceed refund, please select orderlines from the same order.')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The order that will contain the refund orderlines.
|
||||
// Use the destinationOrder from props if the order to refund has the same
|
||||
// partner as the destinationOrder.
|
||||
const destinationOrder =
|
||||
this.props.destinationOrder &&
|
||||
partner === this.props.destinationOrder.get_partner() &&
|
||||
!this.env.pos.doNotAllowRefundAndSales()
|
||||
? this.props.destinationOrder
|
||||
: this._getEmptyOrder(partner);
|
||||
|
||||
//Add a check too see if the fiscal position exist in the pos
|
||||
if (order.fiscal_position_not_found) {
|
||||
this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Fiscal Position not found'),
|
||||
body: this.env._t('The fiscal position used in the original order is not loaded. Make sure it is loaded by adding it in the pos configuration.')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add orderline for each toRefundDetail to the destinationOrder.
|
||||
for (const refundDetail of allToRefundDetails) {
|
||||
const product = this.env.pos.db.get_product_by_id(refundDetail.orderline.productId);
|
||||
const options = this._prepareRefundOrderlineOptions(refundDetail);
|
||||
await destinationOrder.add_product(product, options);
|
||||
refundDetail.destinationOrderUid = destinationOrder.uid;
|
||||
}
|
||||
destinationOrder.fiscal_position = order.fiscal_position;
|
||||
|
||||
// Set the partner to the destinationOrder.
|
||||
if (partner && !destinationOrder.get_partner()) {
|
||||
destinationOrder.set_partner(partner);
|
||||
destinationOrder.updatePricelist(partner);
|
||||
}
|
||||
|
||||
if (this.env.pos.get_order().cid !== destinationOrder.cid) {
|
||||
this.env.pos.set_order(destinationOrder);
|
||||
}
|
||||
|
||||
this._onCloseScreen();
|
||||
}
|
||||
//#endregion
|
||||
//#region PUBLIC METHODS
|
||||
close() {
|
||||
/**
|
||||
* Automatically create new order when there is no currently active order.
|
||||
* Important in fiscal modules to keep the sequence of the orders.
|
||||
*/
|
||||
if (this.env.pos.orders.length == 0) {
|
||||
this.env.pos.add_new_order();
|
||||
}
|
||||
super.close();
|
||||
}
|
||||
getSelectedSyncedOrder() {
|
||||
if (this._state.ui.filter == 'SYNCED') {
|
||||
return this._state.syncedOrders.cache[this._state.ui.selectedSyncedOrderId];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
getSelectedOrderlineId() {
|
||||
return this._state.ui.selectedOrderlineIds[this._state.ui.selectedSyncedOrderId];
|
||||
}
|
||||
/**
|
||||
* Override to conditionally show the new ticket button.
|
||||
*/
|
||||
shouldShowNewOrderButton() {
|
||||
return true;
|
||||
}
|
||||
getFilteredOrderList() {
|
||||
if (this._state.ui.filter == 'SYNCED') return this._state.syncedOrders.toShow;
|
||||
const filterCheck = (order) => {
|
||||
if (this._state.ui.filter && this._state.ui.filter !== 'ACTIVE_ORDERS') {
|
||||
const screen = order.get_screen_data();
|
||||
return this._state.ui.filter === this._getScreenToStatusMap()[screen.name];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const { fieldName, searchTerm } = this._state.ui.searchDetails;
|
||||
const searchField = this._getSearchFields()[fieldName];
|
||||
const searchCheck = (order) => {
|
||||
if (!searchField) return true;
|
||||
const repr = searchField.repr(order);
|
||||
if (repr === null) return true;
|
||||
if (!searchTerm) return true;
|
||||
return repr && repr.toString().toLowerCase().includes(searchTerm.toLowerCase());
|
||||
};
|
||||
const predicate = (order) => {
|
||||
return filterCheck(order) && searchCheck(order);
|
||||
};
|
||||
return this._getOrderList().filter(predicate);
|
||||
}
|
||||
getDate(order) {
|
||||
return moment(order.validation_date).format('YYYY-MM-DD hh:mm A');
|
||||
}
|
||||
getTotal(order) {
|
||||
return this.env.pos.format_currency(order.get_total_with_tax());
|
||||
}
|
||||
getPartner(order) {
|
||||
return order.get_partner_name();
|
||||
}
|
||||
getCardholderName(order) {
|
||||
return order.get_cardholder_name();
|
||||
}
|
||||
getCashier(order) {
|
||||
return order.cashier ? order.cashier.name : '';
|
||||
}
|
||||
getStatus(order) {
|
||||
if (order.locked) {
|
||||
return order.state === 'invoiced' ? this.env._t('Invoiced') : this.env._t('Paid');
|
||||
} else {
|
||||
const screen = order.get_screen_data();
|
||||
return this._getOrderStates().get(this._getScreenToStatusMap()[screen.name]).text;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* If the order is the only order and is empty
|
||||
*/
|
||||
isDefaultOrderEmpty(order) {
|
||||
let status = this._getScreenToStatusMap()[order.get_screen_data().name];
|
||||
let productScreenStatus = this._getScreenToStatusMap().ProductScreen;
|
||||
return order.get_orderlines().length === 0 && this.env.pos.get_order_list().length === 1 &&
|
||||
status === productScreenStatus && order.get_paymentlines().length === 0;
|
||||
}
|
||||
/**
|
||||
* Hide the delete button if one of the payments is a 'done' electronic payment.
|
||||
*/
|
||||
shouldHideDeleteButton(order) {
|
||||
return (
|
||||
this.isDefaultOrderEmpty(order)||
|
||||
order.locked ||
|
||||
order
|
||||
.get_paymentlines()
|
||||
.some((payment) => payment.is_electronic() && payment.get_payment_status() === 'done')
|
||||
);
|
||||
}
|
||||
isHighlighted(order) {
|
||||
if (this._state.ui.filter == 'SYNCED') {
|
||||
const selectedOrder = this.getSelectedSyncedOrder();
|
||||
return selectedOrder ? order.backendId == selectedOrder.backendId : false;
|
||||
} else {
|
||||
const activeOrder = this.env.pos.get_order();
|
||||
return activeOrder ? activeOrder.uid == order.uid : false;
|
||||
}
|
||||
}
|
||||
showCardholderName() {
|
||||
return this.env.pos.payment_methods.some((method) => method.use_payment_terminal);
|
||||
}
|
||||
getSearchBarConfig() {
|
||||
return {
|
||||
searchFields: new Map(
|
||||
Object.entries(this._getSearchFields()).map(([key, val]) => [key, val.displayName])
|
||||
),
|
||||
filter: { show: true, options: this._getFilterOptions() },
|
||||
defaultSearchDetails: this._state.ui.searchDetails,
|
||||
defaultFilter: this._state.ui.filter,
|
||||
};
|
||||
}
|
||||
shouldShowPageControls() {
|
||||
return this._state.ui.filter == 'SYNCED' && this._getLastPage() > 1;
|
||||
}
|
||||
getPageNumber() {
|
||||
if (!this._state.syncedOrders.totalCount) {
|
||||
return `1/1`;
|
||||
} else {
|
||||
return `${this._state.syncedOrders.currentPage}/${this._getLastPage()}`;
|
||||
}
|
||||
}
|
||||
getSelectedPartner() {
|
||||
const order = this.getSelectedSyncedOrder();
|
||||
return order ? order.get_partner() : null;
|
||||
}
|
||||
getHasItemsToRefund() {
|
||||
const order = this.getSelectedSyncedOrder();
|
||||
if (!order) return false;
|
||||
if (this._doesOrderHaveSoleItem(order)) return true;
|
||||
const total = Object.values(this.env.pos.toRefundLines)
|
||||
.filter(
|
||||
(toRefundDetail) =>
|
||||
toRefundDetail.orderline.orderUid === order.uid && !toRefundDetail.destinationOrderUid
|
||||
)
|
||||
.map((toRefundDetail) => toRefundDetail.qty)
|
||||
.reduce((acc, val) => acc + val, 0);
|
||||
return !this.env.pos.isProductQtyZero(total);
|
||||
}
|
||||
//#endregion
|
||||
//#region PRIVATE METHODS
|
||||
/**
|
||||
* Find the empty order with the following priority:
|
||||
* - The empty order with the same parter as the provided.
|
||||
* - The first empty order without a partner.
|
||||
* - If no empty order, create a new one.
|
||||
* @param {Object | null} partner
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_getEmptyOrder(partner) {
|
||||
let emptyOrderForPartner = null;
|
||||
let emptyOrder = null;
|
||||
for (const order of this.env.pos.orders) {
|
||||
if (order.get_orderlines().length === 0 && order.get_paymentlines().length === 0) {
|
||||
if (order.get_partner() === partner) {
|
||||
emptyOrderForPartner = order;
|
||||
break;
|
||||
} else if (!order.get_partner() && emptyOrder === null) {
|
||||
// If emptyOrderForPartner is not found, we will use the first empty order.
|
||||
emptyOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
return emptyOrderForPartner || emptyOrder || this.env.pos.add_new_order();
|
||||
}
|
||||
_doesOrderHaveSoleItem(order) {
|
||||
const orderlines = order.get_orderlines();
|
||||
if (orderlines.length !== 1) return false;
|
||||
const theOrderline = orderlines[0];
|
||||
const refundableQty = theOrderline.get_quantity() - theOrderline.refunded_qty;
|
||||
return this.env.pos.isProductQtyZero(refundableQty - 1);
|
||||
}
|
||||
_prepareAutoRefundOnOrder(order) {
|
||||
const selectedOrderlineId = this.getSelectedOrderlineId();
|
||||
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
|
||||
if (!orderline) return false;
|
||||
|
||||
const toRefundDetail = this._getToRefundDetail(orderline);
|
||||
const refundableQty = orderline.get_quantity() - orderline.refunded_qty;
|
||||
if (this.env.pos.isProductQtyZero(refundableQty - 1) && toRefundDetail.qty === 0) {
|
||||
toRefundDetail.qty = 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Returns the corresponding toRefundDetail of the given orderline.
|
||||
* SIDE-EFFECT: Automatically creates a toRefundDetail object for
|
||||
* the given orderline if it doesn't exist and returns it.
|
||||
* @param {models.Orderline} orderline
|
||||
* @returns
|
||||
*/
|
||||
_getToRefundDetail(orderline) {
|
||||
if (orderline.id in this.env.pos.toRefundLines) {
|
||||
return this.env.pos.toRefundLines[orderline.id];
|
||||
} else {
|
||||
const partner = orderline.order.get_partner();
|
||||
const orderPartnerId = partner ? partner.id : false;
|
||||
const newToRefundDetail = {
|
||||
qty: 0,
|
||||
orderline: {
|
||||
id: orderline.id,
|
||||
productId: orderline.product.id,
|
||||
price: orderline.price,
|
||||
qty: orderline.quantity,
|
||||
refundedQty: orderline.refunded_qty,
|
||||
orderUid: orderline.order.uid,
|
||||
orderBackendId: orderline.order.backendId,
|
||||
orderPartnerId,
|
||||
tax_ids: orderline.get_taxes().map(tax => tax.id),
|
||||
discount: orderline.discount,
|
||||
pack_lot_lines: orderline.pack_lot_lines ? orderline.pack_lot_lines.map(lot => {
|
||||
return { lot_name: lot.lot_name };
|
||||
}) : false,
|
||||
},
|
||||
destinationOrderUid: false,
|
||||
};
|
||||
this.env.pos.toRefundLines[orderline.id] = newToRefundDetail;
|
||||
return newToRefundDetail;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Select the lines from toRefundLines, as they can come from different orders.
|
||||
* Returns only details that:
|
||||
* - The quantity to refund is not zero
|
||||
* - Filtered by partner (optional)
|
||||
* - It's not yet linked to an active order (no destinationOrderUid)
|
||||
*
|
||||
* @param {Object} partner (optional)
|
||||
* @returns {Array} refundableDetails
|
||||
*/
|
||||
_getRefundableDetails(partner) {
|
||||
return Object.values(this.env.pos.toRefundLines).filter(
|
||||
({ qty, orderline, destinationOrderUid }) =>
|
||||
!this.env.pos.isProductQtyZero(qty) &&
|
||||
(partner ? orderline.orderPartnerId == partner.id : true) &&
|
||||
!destinationOrderUid
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Prepares the options to add a refund orderline.
|
||||
*
|
||||
* @param {Object} toRefundDetail
|
||||
* @returns {Object}
|
||||
*/
|
||||
_prepareRefundOrderlineOptions(toRefundDetail) {
|
||||
const { qty, orderline } = toRefundDetail;
|
||||
const draftPackLotLines = orderline.pack_lot_lines ? { modifiedPackLotLines: [], newPackLotLines: orderline.pack_lot_lines} : false;
|
||||
return {
|
||||
quantity: -qty,
|
||||
price: orderline.price,
|
||||
extras: { price_automatically_set: true },
|
||||
merge: false,
|
||||
refunded_orderline_id: orderline.id,
|
||||
tax_ids: orderline.tax_ids,
|
||||
discount: orderline.discount,
|
||||
draftPackLotLines: draftPackLotLines
|
||||
};
|
||||
}
|
||||
_setOrder(order) {
|
||||
this.env.pos.set_order(order);
|
||||
this.close();
|
||||
}
|
||||
_getOrderList() {
|
||||
return this.env.pos.get_order_list();
|
||||
}
|
||||
_getFilterOptions() {
|
||||
const orderStates = this._getOrderStates();
|
||||
orderStates.set('SYNCED', { text: this.env._t('Paid') });
|
||||
return orderStates;
|
||||
}
|
||||
/**
|
||||
* @returns {Record<string, { repr: (order: models.Order) => string, displayName: string, modelField: string }>}
|
||||
*/
|
||||
_getSearchFields() {
|
||||
const fields = {
|
||||
RECEIPT_NUMBER: {
|
||||
repr: (order) => order.name,
|
||||
displayName: this.env._t('Receipt Number'),
|
||||
modelField: 'pos_reference',
|
||||
},
|
||||
DATE: {
|
||||
repr: (order) => moment(order.creation_date).format('YYYY-MM-DD hh:mm A'),
|
||||
displayName: this.env._t('Date'),
|
||||
modelField: 'date_order',
|
||||
},
|
||||
PARTNER: {
|
||||
repr: (order) => order.get_partner_name(),
|
||||
displayName: this.env._t('Customer'),
|
||||
modelField: 'partner_id.display_name',
|
||||
},
|
||||
};
|
||||
|
||||
if (this.showCardholderName()) {
|
||||
fields.CARDHOLDER_NAME = {
|
||||
repr: (order) => order.get_cardholder_name(),
|
||||
displayName: this.env._t('Cardholder Name'),
|
||||
modelField: 'payment_ids.cardholder_name',
|
||||
};
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
/**
|
||||
* Maps the order screen params to order status.
|
||||
*/
|
||||
_getScreenToStatusMap() {
|
||||
return {
|
||||
ProductScreen: 'ONGOING',
|
||||
PaymentScreen: 'PAYMENT',
|
||||
ReceiptScreen: 'RECEIPT',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Override to do something before deleting the order.
|
||||
* Make sure to return true to proceed on deleting the order.
|
||||
* @param {*} order
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async _onBeforeDeleteOrder(order) {
|
||||
return true;
|
||||
}
|
||||
_getOrderStates() {
|
||||
// We need the items to be ordered, therefore, Map is used instead of normal object.
|
||||
const states = new Map();
|
||||
states.set('ACTIVE_ORDERS', {
|
||||
text: this.env._t('All active orders'),
|
||||
});
|
||||
// The spaces are important to make sure the following states
|
||||
// are under the category of `All active orders`.
|
||||
states.set('ONGOING', {
|
||||
text: this.env._t('Ongoing'),
|
||||
indented: true,
|
||||
});
|
||||
states.set('PAYMENT', {
|
||||
text: this.env._t('Payment'),
|
||||
indented: true,
|
||||
});
|
||||
states.set('RECEIPT', {
|
||||
text: this.env._t('Receipt'),
|
||||
indented: true,
|
||||
});
|
||||
return states;
|
||||
}
|
||||
//#region SEARCH SYNCED ORDERS
|
||||
_computeSyncedOrdersDomain() {
|
||||
const { fieldName, searchTerm } = this._state.ui.searchDetails;
|
||||
if (!searchTerm) return [];
|
||||
const modelField = this._getSearchFields()[fieldName].modelField;
|
||||
if (modelField) {
|
||||
return [[modelField, 'ilike', `%${searchTerm}%`]];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetches the done orders from the backend that needs to be shown.
|
||||
* If the order is already in cache, the full information about that
|
||||
* order is not fetched anymore, instead, we use info from cache.
|
||||
*/
|
||||
async _fetchSyncedOrders() {
|
||||
const domain = this._computeSyncedOrdersDomain();
|
||||
const limit = this._state.syncedOrders.nPerPage;
|
||||
const offset = (this._state.syncedOrders.currentPage - 1) * this._state.syncedOrders.nPerPage;
|
||||
let { ids, totalCount } = await this.rpc({
|
||||
model: 'pos.order',
|
||||
method: 'search_paid_order_ids',
|
||||
kwargs: { config_id: this.env.pos.config.id, domain, limit, offset },
|
||||
context: this.env.session.user_context,
|
||||
});
|
||||
const idsNotInCache = ids.filter((id) => !(id in this._state.syncedOrders.cache));
|
||||
if (idsNotInCache.length > 0) {
|
||||
const fetchedOrders = await this.rpc({
|
||||
model: 'pos.order',
|
||||
method: 'export_for_ui',
|
||||
args: [idsNotInCache],
|
||||
context: this.env.session.user_context,
|
||||
});
|
||||
// Remove not loaded Order IDs
|
||||
const fetchedOrderIdsSet = new Set(fetchedOrders.map(order => order.id));
|
||||
const notLoadedIds = idsNotInCache.filter(id => !fetchedOrderIdsSet.has(id));
|
||||
ids = ids.filter(id => !notLoadedIds.includes(id));
|
||||
|
||||
// Check for missing products and partners and load them in the PoS
|
||||
await this.env.pos._loadMissingProducts(fetchedOrders);
|
||||
await this.env.pos._loadMissingPartners(fetchedOrders);
|
||||
// Cache these fetched orders so that next time, no need to fetch
|
||||
// them again, unless invalidated. See `_onInvoiceOrder`.
|
||||
fetchedOrders.forEach((order) => {
|
||||
this._state.syncedOrders.cache[order.id] = Order.create({}, { pos: this.env.pos, json: order });
|
||||
});
|
||||
}
|
||||
this._state.syncedOrders.totalCount = totalCount;
|
||||
this._state.syncedOrders.toShow = ids.map((id) => this._state.syncedOrders.cache[id]);
|
||||
}
|
||||
_getLastPage() {
|
||||
const totalCount = this._state.syncedOrders.totalCount;
|
||||
const nPerPage = this._state.syncedOrders.nPerPage;
|
||||
const remainder = totalCount % nPerPage;
|
||||
if (remainder == 0) {
|
||||
return totalCount / nPerPage;
|
||||
} else {
|
||||
return Math.ceil(totalCount / nPerPage);
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
//#endregion
|
||||
}
|
||||
TicketScreen.template = 'TicketScreen';
|
||||
TicketScreen.defaultProps = {
|
||||
destinationOrder: null,
|
||||
// When passed as true, it will use the saved _state.ui as default
|
||||
// value when this component is reinstantiated.
|
||||
// After setting the default value, the _state.ui will be overridden
|
||||
// by the passed props.ui if there is any.
|
||||
reuseSavedUIState: false,
|
||||
ui: {},
|
||||
};
|
||||
|
||||
Registries.Component.add(TicketScreen);
|
||||
TicketScreen.numpadActionName = _lt('Refund');
|
||||
TicketScreen.searchPlaceholder = _lt('Search Orders...');
|
||||
|
||||
return TicketScreen;
|
||||
});
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
odoo.define('point_of_sale.BarcodeReader', function (require) {
|
||||
"use strict";
|
||||
|
||||
var concurrency = require('web.concurrency');
|
||||
var core = require('web.core');
|
||||
var Mutex = concurrency.Mutex;
|
||||
const { GS1BarcodeError } = require('barcodes_gs1_nomenclature/static/src/js/barcode_parser.js');
|
||||
|
||||
// this module interfaces with the barcode reader. It assumes the barcode reader
|
||||
// is set-up to act like a keyboard. Use connect() and disconnect() to activate
|
||||
// and deactivate the barcode reader. Use set_action_callbacks to tell it
|
||||
// what to do when it reads a barcode.
|
||||
var BarcodeReader = core.Class.extend({
|
||||
actions:[
|
||||
'product',
|
||||
'cashier',
|
||||
'client',
|
||||
],
|
||||
|
||||
init: function (attributes) {
|
||||
this.mutex = new Mutex();
|
||||
this.action_callbacks = {};
|
||||
this.exclusive_callbacks = {};
|
||||
this.proxy = attributes.proxy;
|
||||
this.env = attributes.env;
|
||||
this.remote_scanning = false;
|
||||
this.remote_active = 0;
|
||||
|
||||
this.barcode_parser = attributes.barcode_parser;
|
||||
|
||||
this.action_callback_stack = [];
|
||||
|
||||
core.bus.on('barcode_scanned', this, function (barcode) {
|
||||
// use mutex to make sure scans are done one after the other
|
||||
this.mutex.exec(async () => {
|
||||
await this.scan(barcode);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
set_barcode_parser: function (barcode_parser) {
|
||||
this.barcode_parser = barcode_parser;
|
||||
},
|
||||
setFallbackBarcodeParser: function (fallbackBarcodeParser) {
|
||||
this.fallbackBarcodeParser = fallbackBarcodeParser;
|
||||
},
|
||||
|
||||
// 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))
|
||||
//
|
||||
// callbacks is a Map of 'actions' : callback(parsed_barcode)
|
||||
// that sets the callback for each action. if a callback for the
|
||||
// specified action already exists, it is replaced.
|
||||
//
|
||||
// possible actions include :
|
||||
// 'product' | 'cashier' | 'client' | 'discount'
|
||||
set_action_callback: function (name, callback) {
|
||||
if (this.action_callbacks[name]) {
|
||||
this.action_callbacks[name].add(callback);
|
||||
} else {
|
||||
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];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: async function (code) {
|
||||
if (!code) return;
|
||||
|
||||
const callbacks = Object.keys(this.exclusive_callbacks).length
|
||||
? this.exclusive_callbacks
|
||||
: this.action_callbacks;
|
||||
let parsed_result;
|
||||
try {
|
||||
parsed_result = this.barcode_parser.parse_barcode(code);
|
||||
if (Array.isArray(parsed_result) && !parsed_result.some(element => element.type === 'product')) {
|
||||
throw new GS1BarcodeError('The GS1 barcode must contain a product.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.fallbackBarcodeParser && error instanceof GS1BarcodeError) {
|
||||
parsed_result = this.fallbackBarcodeParser.parse_barcode(code);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(parsed_result)) {
|
||||
[...callbacks.gs1].map(cb => cb(parsed_result));
|
||||
} else {
|
||||
if (callbacks[parsed_result.type]) {
|
||||
for (const cb of callbacks[parsed_result.type]) {
|
||||
await cb(parsed_result);
|
||||
}
|
||||
} else if (callbacks.error) {
|
||||
[...callbacks.error].map(cb => cb(parsed_result));
|
||||
} else {
|
||||
console.warn('Ignored Barcode Scan:', parsed_result);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// the barcode scanner will listen on the hw_proxy/scanner interface for
|
||||
// scan events until disconnect_from_proxy is called
|
||||
connect_to_proxy: function () {
|
||||
var self = this;
|
||||
this.remote_scanning = true;
|
||||
if (this.remote_active >= 1) {
|
||||
return;
|
||||
}
|
||||
this.remote_active = 1;
|
||||
|
||||
function waitforbarcode(){
|
||||
return self.proxy.connection.rpc('/hw_proxy/scanner',{},{shadow: true, timeout:7500})
|
||||
.then(function (barcode) {
|
||||
if (!self.remote_scanning) {
|
||||
self.remote_active = 0;
|
||||
return;
|
||||
}
|
||||
self.scan(barcode);
|
||||
waitforbarcode();
|
||||
},
|
||||
function () {
|
||||
if (!self.remote_scanning) {
|
||||
self.remote_active = 0;
|
||||
return;
|
||||
}
|
||||
waitforbarcode();
|
||||
});
|
||||
}
|
||||
waitforbarcode();
|
||||
},
|
||||
|
||||
// the barcode scanner will stop listening on the hw_proxy/scanner remote interface
|
||||
disconnect_from_proxy: function () {
|
||||
this.remote_scanning = false;
|
||||
},
|
||||
});
|
||||
|
||||
return BarcodeReader;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
odoo.define('point_of_sale.custom_hooks', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { onMounted, onPatched, onWillUnmount, useComponent, useRef } = owl;
|
||||
const { escapeRegExp } = require('@web/core/utils/strings');
|
||||
|
||||
/**
|
||||
* 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 = useComponent();
|
||||
|
||||
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 if (error.code === 700) {
|
||||
// Sweden Fiscal module errors
|
||||
await this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Fiscal data module error'),
|
||||
body:
|
||||
error.data.error.status ||
|
||||
this.env._t('The fiscal data module encountered an error while receiving your order.'),
|
||||
});
|
||||
} else if (error.code === 701) {
|
||||
// Belgian Fiscal module errors
|
||||
let bodyMessage = "";
|
||||
if(error.error.errorCode)
|
||||
bodyMessage = "'" + error.error.errorCode + "': " + error.error.errorMessage;
|
||||
else
|
||||
bodyMessage = "Fiscal data module is not on.";
|
||||
await this.showPopup('ErrorPopup', {
|
||||
title: this.env._t('Fiscal data module error'),
|
||||
body: bodyMessage
|
||||
});
|
||||
} 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 = useComponent();
|
||||
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);
|
||||
}
|
||||
|
||||
function useBarcodeReader(callbackMap, exclusive = false) {
|
||||
const current = useComponent();
|
||||
const barcodeReader = current.env.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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function useValidateCashInput(inputRef, startingValue) {
|
||||
const cashInput = useRef(inputRef);
|
||||
const current = useComponent();
|
||||
const decimalPoint = current.env._t.database.parameters.decimal_point;
|
||||
const thousandsSep = current.env._t.database.parameters.thousands_sep;
|
||||
// Replace the thousands separator and decimal point with regex-escaped versions
|
||||
const escapedDecimalPoint = escapeRegExp(decimalPoint);
|
||||
let floatRegex;
|
||||
if (thousandsSep) {
|
||||
const escapedThousandsSep = escapeRegExp(thousandsSep);
|
||||
floatRegex = new RegExp(`^-?(?:\\d+(${escapedThousandsSep}\\d+)*)?(?:${escapedDecimalPoint}\\d*)?$`);
|
||||
} else {
|
||||
floatRegex = new RegExp(`^-?(?:\\d+)?(?:${escapedDecimalPoint}\\d*)?$`);
|
||||
}
|
||||
function isValidFloat(inputValue) {
|
||||
return ![decimalPoint, '-'].includes(inputValue) && floatRegex.test(inputValue);
|
||||
}
|
||||
function handleCashInputChange(event) {
|
||||
let inputValue = (event.target.value || "").trim();
|
||||
|
||||
// Check if the current input value is a valid float
|
||||
if (!isValidFloat(inputValue)) {
|
||||
event.target.classList.add('invalid-cash-input');
|
||||
} else {
|
||||
event.target.classList.remove('invalid-cash-input');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (cashInput.el) {
|
||||
cashInput.el.value = (startingValue || 0).toString().replace('.', decimalPoint);
|
||||
cashInput.el.addEventListener("input", handleCashInputChange);
|
||||
}
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (cashInput.el) {
|
||||
cashInput.el.removeEventListener("input", handleCashInputChange);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function useAsyncLockedMethod(method) {
|
||||
const component = useComponent();
|
||||
let called = false;
|
||||
return async (...args) => {
|
||||
if (called) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
called = true;
|
||||
await method.call(component, ...args);
|
||||
} finally {
|
||||
called = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { useErrorHandlers, useAutoFocusToLast, useBarcodeReader, useValidateCashInput, useAsyncLockedMethod };
|
||||
});
|
||||
|
|
@ -0,0 +1,581 @@
|
|||
odoo.define('point_of_sale.DB', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var utils = require('web.utils');
|
||||
/* The PosDB holds reference to data that is either
|
||||
* - static: does not change between pos reloads
|
||||
* - persistent : must stay between reloads ( orders )
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* cache the data in memory to avoid roundtrips to the localstorage
|
||||
*
|
||||
* NOTE/TODO: Originally, this is a prop of PosDB. However, if we keep it that way,
|
||||
* caching will result to infinite loop to calling the reactive callbacks.
|
||||
* Another way to solve the infinite loop is to move the instance of PosDB to env.
|
||||
* But I'm not sure if there is anything inside the object that needs to be observed,
|
||||
* so I guess this strategy is good enough for the moment.
|
||||
*/
|
||||
const CACHE = {};
|
||||
|
||||
var PosDB = core.Class.extend({
|
||||
name: 'openerp_pos_db', //the prefix of the localstorage data
|
||||
limit: 100, // the maximum number of results returned by a search
|
||||
init: function(options){
|
||||
options = options || {};
|
||||
this.name = options.name || this.name;
|
||||
this.limit = options.limit || this.limit;
|
||||
|
||||
if (options.uuid) {
|
||||
this.name = this.name + '_' + options.uuid;
|
||||
}
|
||||
|
||||
this.product_by_id = {};
|
||||
this.product_by_barcode = {};
|
||||
this.product_by_category_id = {};
|
||||
this.product_packaging_by_barcode = {};
|
||||
|
||||
this.partner_sorted = [];
|
||||
this.partner_by_id = {};
|
||||
this.partner_by_barcode = {};
|
||||
// FIXME before master: partner_search_string is no longer used but is kept for partial
|
||||
// compatibility with customizations. The string is no longer useful but we don't want
|
||||
// a custo to crash when calling a method (eg .split()) on it.
|
||||
this.partner_search_string = "";
|
||||
this.partner_search_strings = {};
|
||||
this.partner_write_date = null;
|
||||
|
||||
this.category_by_id = {};
|
||||
this.root_category_id = 0;
|
||||
this.category_products = {};
|
||||
this.category_ancestors = {};
|
||||
this.category_childs = {};
|
||||
this.category_parent = {};
|
||||
this.category_search_string = {};
|
||||
},
|
||||
|
||||
/**
|
||||
* sets an uuid to prevent conflict in locally stored data between multiple PoS Configs. By
|
||||
* using the uuid of the config the local storage from other configs will not get effected nor
|
||||
* loaded in sessions that don't belong to them.
|
||||
*
|
||||
* @param {string} uuid Unique identifier of the PoS Config linked to the current session.
|
||||
*/
|
||||
set_uuid: function(uuid){
|
||||
this.name = this.name + '_' + uuid;
|
||||
},
|
||||
|
||||
/* returns the category object from its id. If you pass a list of id as parameters, you get
|
||||
* a list of category objects.
|
||||
*/
|
||||
get_category_by_id: function(categ_id){
|
||||
if(categ_id instanceof Array){
|
||||
var list = [];
|
||||
for(var i = 0, len = categ_id.length; i < len; i++){
|
||||
var cat = this.category_by_id[categ_id[i]];
|
||||
if(cat){
|
||||
list.push(cat);
|
||||
}else{
|
||||
console.error("get_category_by_id: no category has id:",categ_id[i]);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}else{
|
||||
return this.category_by_id[categ_id];
|
||||
}
|
||||
},
|
||||
/* returns a list of the category's child categories ids, or an empty list
|
||||
* if a category has no childs */
|
||||
get_category_childs_ids: function(categ_id){
|
||||
return this.category_childs[categ_id] || [];
|
||||
},
|
||||
/* returns a list of all ancestors (parent, grand-parent, etc) categories ids
|
||||
* starting from the root category to the direct parent */
|
||||
get_category_ancestors_ids: function(categ_id){
|
||||
return this.category_ancestors[categ_id] || [];
|
||||
},
|
||||
/* returns the parent category's id of a category, or the root_category_id if no parent.
|
||||
* the root category is parent of itself. */
|
||||
get_category_parent_id: function(categ_id){
|
||||
return this.category_parent[categ_id] || this.root_category_id;
|
||||
},
|
||||
/* adds categories definitions to the database. categories is a list of categories objects as
|
||||
* returned by the openerp server. Categories must be inserted before the products or the
|
||||
* product/ categories association may (will) not work properly */
|
||||
add_categories: function(categories){
|
||||
var self = this;
|
||||
if(!this.category_by_id[this.root_category_id]){
|
||||
this.category_by_id[this.root_category_id] = {
|
||||
id : this.root_category_id,
|
||||
name : 'Root',
|
||||
};
|
||||
}
|
||||
categories.forEach(function(cat){
|
||||
self.category_by_id[cat.id] = cat;
|
||||
});
|
||||
categories.forEach(function(cat){
|
||||
var parent_id = cat.parent_id[0];
|
||||
if(!(parent_id && self.category_by_id[parent_id])){
|
||||
parent_id = self.root_category_id;
|
||||
}
|
||||
self.category_parent[cat.id] = parent_id;
|
||||
if(!self.category_childs[parent_id]){
|
||||
self.category_childs[parent_id] = [];
|
||||
}
|
||||
self.category_childs[parent_id].push(cat.id);
|
||||
});
|
||||
function make_ancestors(cat_id, ancestors){
|
||||
self.category_ancestors[cat_id] = ancestors;
|
||||
|
||||
ancestors = ancestors.slice(0);
|
||||
ancestors.push(cat_id);
|
||||
|
||||
var childs = self.category_childs[cat_id] || [];
|
||||
for(var i=0, len = childs.length; i < len; i++){
|
||||
make_ancestors(childs[i], ancestors);
|
||||
}
|
||||
}
|
||||
make_ancestors(this.root_category_id, []);
|
||||
},
|
||||
category_contains: function(categ_id, product_id) {
|
||||
var product = this.product_by_id[product_id];
|
||||
if (product) {
|
||||
var cid = product.pos_categ_id[0];
|
||||
while (cid && cid !== categ_id){
|
||||
cid = this.category_parent[cid];
|
||||
}
|
||||
return !!cid;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/* loads a record store from the database. returns default if nothing is found */
|
||||
load: function(store,deft){
|
||||
if(CACHE[store] !== undefined){
|
||||
return CACHE[store];
|
||||
}
|
||||
var data = localStorage[this.name + '_' + store];
|
||||
if(data !== undefined && data !== ""){
|
||||
data = JSON.parse(data);
|
||||
CACHE[store] = data;
|
||||
return data;
|
||||
}else{
|
||||
return deft;
|
||||
}
|
||||
},
|
||||
/* saves a record store to the database */
|
||||
save: function(store,data){
|
||||
localStorage[this.name + '_' + store] = JSON.stringify(data);
|
||||
CACHE[store] = data;
|
||||
},
|
||||
_product_search_string: function(product){
|
||||
var str = product.display_name;
|
||||
if (product.barcode) {
|
||||
str += '|' + product.barcode;
|
||||
}
|
||||
if (product.default_code) {
|
||||
str += '|' + product.default_code;
|
||||
}
|
||||
if (product.description) {
|
||||
str += '|' + product.description;
|
||||
}
|
||||
if (product.description_sale) {
|
||||
str += '|' + product.description_sale;
|
||||
}
|
||||
str = product.id + ':' + str.replace(/[\n:]/g,'') + '\n';
|
||||
return str;
|
||||
},
|
||||
add_products: function(products){
|
||||
var stored_categories = this.product_by_category_id;
|
||||
|
||||
if(!(products instanceof Array)){
|
||||
products = [products];
|
||||
}
|
||||
for(var i = 0, len = products.length; i < len; i++){
|
||||
var product = products[i];
|
||||
if (product.id in this.product_by_id) continue;
|
||||
if (product.available_in_pos){
|
||||
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];
|
||||
if(!stored_categories[categ_id]){
|
||||
stored_categories[categ_id] = [];
|
||||
}
|
||||
stored_categories[categ_id].push(product.id);
|
||||
|
||||
if(this.category_search_string[categ_id] === undefined){
|
||||
this.category_search_string[categ_id] = '';
|
||||
}
|
||||
this.category_search_string[categ_id] += search_string;
|
||||
|
||||
var ancestors = this.get_category_ancestors_ids(categ_id) || [];
|
||||
|
||||
for(var j = 0, jlen = ancestors.length; j < jlen; j++){
|
||||
var ancestor = ancestors[j];
|
||||
if(! stored_categories[ancestor]){
|
||||
stored_categories[ancestor] = [];
|
||||
}
|
||||
stored_categories[ancestor].push(product.id);
|
||||
|
||||
if( this.category_search_string[ancestor] === undefined){
|
||||
this.category_search_string[ancestor] = '';
|
||||
}
|
||||
this.category_search_string[ancestor] += search_string;
|
||||
}
|
||||
}
|
||||
this.product_by_id[product.id] = product;
|
||||
if(product.barcode && product.active){
|
||||
this.product_by_barcode[product.barcode] = product;
|
||||
}
|
||||
}
|
||||
},
|
||||
add_packagings: function(productPackagings){
|
||||
productPackagings.forEach(productPackaging => {
|
||||
if (productPackaging.product_id[0] in this.product_by_id) {
|
||||
this.product_packaging_by_barcode[productPackaging.barcode] = productPackaging;
|
||||
}
|
||||
});
|
||||
},
|
||||
_partner_search_string: function(partner){
|
||||
var str = partner.name || '';
|
||||
if(partner.barcode){
|
||||
str += '|' + partner.barcode;
|
||||
}
|
||||
if(partner.address){
|
||||
str += '|' + partner.address;
|
||||
}
|
||||
if(partner.phone){
|
||||
str += '|' + partner.phone.split(' ').join('');
|
||||
}
|
||||
if(partner.mobile){
|
||||
str += '|' + partner.mobile.split(' ').join('');
|
||||
}
|
||||
if(partner.email){
|
||||
str += '|' + partner.email;
|
||||
}
|
||||
if(partner.vat){
|
||||
str += '|' + partner.vat;
|
||||
}
|
||||
if(partner.parent_name){
|
||||
str += '|' + partner.parent_name;
|
||||
}
|
||||
str = '' + partner.id + ':' + str.replace(':', '').replace(/\n/g, ' ') + '\n';
|
||||
return str;
|
||||
},
|
||||
add_partners: function(partners){
|
||||
var updated = {};
|
||||
var new_write_date = '';
|
||||
var partner;
|
||||
for(var i = 0, len = partners.length; i < len; i++){
|
||||
partner = partners[i];
|
||||
|
||||
var local_partner_date = (this.partner_write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z');
|
||||
var dist_partner_date = (partner.write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z');
|
||||
if ( this.partner_write_date &&
|
||||
this.partner_by_id[partner.id] &&
|
||||
new Date(local_partner_date).getTime() + 1000 >=
|
||||
new Date(dist_partner_date).getTime() ) {
|
||||
// FIXME: The write_date is stored with milisec precision in the database
|
||||
// but the dates we get back are only precise to the second. This means when
|
||||
// you read partners modified strictly after time X, you get back partners that were
|
||||
// modified X - 1 sec ago.
|
||||
continue;
|
||||
} else if ( new_write_date < partner.write_date ) {
|
||||
new_write_date = partner.write_date;
|
||||
}
|
||||
if (!this.partner_by_id[partner.id]) {
|
||||
this.partner_sorted.push(partner.id);
|
||||
} else {
|
||||
const oldPartner = this.partner_by_id[partner.id];
|
||||
if (oldPartner.barcode) {
|
||||
delete this.partner_by_barcode[oldPartner.barcode];
|
||||
}
|
||||
}
|
||||
if (partner.barcode) {
|
||||
this.partner_by_barcode[partner.barcode] = partner;
|
||||
}
|
||||
updated[partner.id] = partner;
|
||||
this.partner_by_id[partner.id] = partner;
|
||||
}
|
||||
|
||||
this.partner_write_date = new_write_date || this.partner_write_date;
|
||||
|
||||
const updatedChunks = new Set();
|
||||
const CHUNK_SIZE = 100;
|
||||
for (const id in updated) {
|
||||
const chunkId = Math.floor(id / CHUNK_SIZE);
|
||||
if (updatedChunks.has(chunkId)) {
|
||||
// another partner in this chunk was updated and we already rebuild the chunk
|
||||
continue;
|
||||
}
|
||||
updatedChunks.add(chunkId);
|
||||
// If there were updates, we need to rebuild the search string for this chunk
|
||||
let searchString = "";
|
||||
|
||||
for (let id = chunkId * CHUNK_SIZE; id < (chunkId + 1) * CHUNK_SIZE; id++) {
|
||||
if (!(id in this.partner_by_id)) {
|
||||
continue;
|
||||
}
|
||||
const partner = this.partner_by_id[id];
|
||||
partner.address = (partner.street ? partner.street + ', ': '') +
|
||||
(partner.zip ? partner.zip + ', ': '') +
|
||||
(partner.city ? partner.city + ', ': '') +
|
||||
(partner.state_id ? partner.state_id[1] + ', ': '') +
|
||||
(partner.country_id ? partner.country_id[1]: '');
|
||||
searchString += this._partner_search_string(partner);
|
||||
}
|
||||
|
||||
this.partner_search_strings[chunkId] = utils.unaccent(searchString);
|
||||
}
|
||||
return Object.keys(updated).length;
|
||||
},
|
||||
get_partner_write_date: function(){
|
||||
return this.partner_write_date || "1970-01-01 00:00:00";
|
||||
},
|
||||
get_partner_by_id: function(id){
|
||||
return this.partner_by_id[id];
|
||||
},
|
||||
get_partner_by_barcode: function(barcode){
|
||||
return this.partner_by_barcode[barcode];
|
||||
},
|
||||
get_partners_sorted: function(max_count){
|
||||
max_count = max_count ? Math.min(this.partner_sorted.length, max_count) : this.partner_sorted.length;
|
||||
var partners = [];
|
||||
for (var i = 0; i < max_count; i++) {
|
||||
partners.push(this.partner_by_id[this.partner_sorted[i]]);
|
||||
}
|
||||
return partners;
|
||||
},
|
||||
search_partner: function(query){
|
||||
try {
|
||||
query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.');
|
||||
query = query.replace(/ /g,'.+');
|
||||
var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi");
|
||||
}catch(_e){
|
||||
return [];
|
||||
}
|
||||
var results = [];
|
||||
const searchStrings = Object.values(this.partner_search_strings).reverse();
|
||||
let searchString = searchStrings.pop();
|
||||
while (searchString && results.length < this.limit) {
|
||||
var r = re.exec(searchString);
|
||||
if(r){
|
||||
var id = Number(r[1]);
|
||||
results.push(this.get_partner_by_id(id));
|
||||
} else {
|
||||
searchString = searchStrings.pop();
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
/* removes all the data from the database. TODO : being able to selectively remove data */
|
||||
clear: function(){
|
||||
for(var i = 0, len = arguments.length; i < len; i++){
|
||||
localStorage.removeItem(this.name + '_' + arguments[i]);
|
||||
}
|
||||
},
|
||||
/* this internal methods returns the count of properties in an object. */
|
||||
_count_props : function(obj){
|
||||
var count = 0;
|
||||
for(var prop in obj){
|
||||
if(obj.hasOwnProperty(prop)){
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
get_product_by_id: function(id){
|
||||
return this.product_by_id[id];
|
||||
},
|
||||
get_product_by_barcode: function(barcode){
|
||||
if(this.product_by_barcode[barcode]){
|
||||
return this.product_by_barcode[barcode];
|
||||
} else if (this.product_packaging_by_barcode[barcode]) {
|
||||
return this.product_by_id[this.product_packaging_by_barcode[barcode].product_id[0]];
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
get_product_by_category: function(category_id){
|
||||
var product_ids = this.product_by_category_id[category_id];
|
||||
var list = [];
|
||||
if (product_ids) {
|
||||
for (var i = 0, len = Math.min(product_ids.length, this.limit); i < len; i++) {
|
||||
const product = this.product_by_id[product_ids[i]];
|
||||
if (!(product.active && product.available_in_pos)) continue;
|
||||
list.push(product);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
},
|
||||
/* returns a list of products with :
|
||||
* - a category that is or is a child of category_id,
|
||||
* - a name, package or barcode containing the query (case insensitive)
|
||||
*/
|
||||
search_product_in_category: function(category_id, query){
|
||||
try {
|
||||
query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.');
|
||||
query = query.replace(/ /g,'.+');
|
||||
var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi");
|
||||
}catch(_e){
|
||||
return [];
|
||||
}
|
||||
var results = [];
|
||||
for(var i = 0; i < this.limit; i++){
|
||||
var r = re.exec(this.category_search_string[category_id]);
|
||||
if(r){
|
||||
var id = Number(r[1]);
|
||||
const product = this.get_product_by_id(id);
|
||||
if (!(product.active && product.available_in_pos)) continue;
|
||||
results.push(product);
|
||||
}else{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
/* from a product id, and a list of category ids, returns
|
||||
* true if the product belongs to one of the provided category
|
||||
* or one of its child categories.
|
||||
*/
|
||||
is_product_in_category: function(category_ids, product_id) {
|
||||
let cat = this.get_product_by_id(product_id).pos_categ_id[0];
|
||||
while (cat) {
|
||||
for (let cat_id of category_ids) {
|
||||
if (cat == cat_id) { // The == is important, ids may be strings
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cat = this.get_category_parent_id(cat);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/* paid orders */
|
||||
add_order: function(order){
|
||||
var order_id = order.uid;
|
||||
var orders = this.load('orders',[]);
|
||||
|
||||
// if the order was already stored, we overwrite its data
|
||||
for(var i = 0, len = orders.length; i < len; i++){
|
||||
if(orders[i].id === order_id){
|
||||
orders[i].data = order;
|
||||
this.save('orders',orders);
|
||||
return order_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Only necessary when we store a new, validated order. Orders
|
||||
// that where already stored should already have been removed.
|
||||
this.remove_unpaid_order(order);
|
||||
|
||||
orders.push({id: order_id, data: order});
|
||||
this.save('orders',orders);
|
||||
return order_id;
|
||||
},
|
||||
remove_order: function(order_id){
|
||||
var orders = this.load('orders',[]);
|
||||
orders = _.filter(orders, function(order){
|
||||
return order.id !== order_id;
|
||||
});
|
||||
this.save('orders',orders);
|
||||
},
|
||||
remove_all_orders: function(){
|
||||
this.save('orders',[]);
|
||||
},
|
||||
get_orders: function(){
|
||||
return this.load('orders',[]);
|
||||
},
|
||||
get_order: function(order_id){
|
||||
var orders = this.get_orders();
|
||||
for(var i = 0, len = orders.length; i < len; i++){
|
||||
if(orders[i].id === order_id){
|
||||
return orders[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/* working orders */
|
||||
save_unpaid_order: function(order){
|
||||
var order_id = order.uid;
|
||||
var orders = this.load('unpaid_orders',[]);
|
||||
var serialized = order.export_as_JSON();
|
||||
|
||||
for (var i = 0; i < orders.length; i++) {
|
||||
if (orders[i].id === order_id){
|
||||
orders[i].data = serialized;
|
||||
this.save('unpaid_orders',orders);
|
||||
return order_id;
|
||||
}
|
||||
}
|
||||
|
||||
orders.push({id: order_id, data: serialized});
|
||||
this.save('unpaid_orders',orders);
|
||||
return order_id;
|
||||
},
|
||||
remove_unpaid_order: function(order){
|
||||
var orders = this.load('unpaid_orders',[]);
|
||||
orders = _.filter(orders, function(o){
|
||||
return o.id !== order.uid;
|
||||
});
|
||||
this.save('unpaid_orders',orders);
|
||||
},
|
||||
remove_all_unpaid_orders: function(){
|
||||
this.save('unpaid_orders',[]);
|
||||
},
|
||||
get_unpaid_orders: function(){
|
||||
var saved = this.load('unpaid_orders',[]);
|
||||
var orders = [];
|
||||
for (var i = 0; i < saved.length; i++) {
|
||||
orders.push(saved[i].data);
|
||||
}
|
||||
return orders;
|
||||
},
|
||||
/**
|
||||
* Return the orders with requested ids if they are unpaid.
|
||||
* @param {array<number>} ids order_ids.
|
||||
* @return {array<object>} list of orders.
|
||||
*/
|
||||
get_unpaid_orders_to_sync: function(ids){
|
||||
const savedOrders = this.load('unpaid_orders',[]);
|
||||
return savedOrders.filter(order => ids.includes(order.id) && (order.data.server_id || order.data.lines.length || order.data.statement_ids.length));
|
||||
},
|
||||
/**
|
||||
* Add a given order to the orders to be removed from the server.
|
||||
*
|
||||
* If an order is removed from a table it also has to be removed from the server to prevent it from reapearing
|
||||
* after syncing. This function will add the server_id of the order to a list of orders still to be removed.
|
||||
* @param {object} order object.
|
||||
*/
|
||||
set_order_to_remove_from_server: function(order){
|
||||
if (order.server_id !== undefined) {
|
||||
var to_remove = this.load('unpaid_orders_to_remove',[]);
|
||||
to_remove.push(order.server_id);
|
||||
this.save('unpaid_orders_to_remove', to_remove);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get a list of server_ids of orders to be removed.
|
||||
* @return {array<number>} list of server_ids.
|
||||
*/
|
||||
get_ids_to_remove_from_server: function(){
|
||||
return this.load('unpaid_orders_to_remove',[]);
|
||||
},
|
||||
/**
|
||||
* Remove server_ids from the list of orders to be removed.
|
||||
* @param {array<number>} ids
|
||||
*/
|
||||
set_ids_removed_from_server: function(ids){
|
||||
var to_remove = this.load('unpaid_orders_to_remove',[]);
|
||||
|
||||
to_remove = _.filter(to_remove, function(id){
|
||||
return !ids.includes(id);
|
||||
});
|
||||
this.save('unpaid_orders_to_remove', to_remove);
|
||||
},
|
||||
});
|
||||
|
||||
return PosDB;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
odoo.define('point_of_sale.devices', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var mixins = require('web.mixins');
|
||||
var Session = require('web.Session');
|
||||
var Printer = require('point_of_sale.Printer').Printer;
|
||||
|
||||
// the JobQueue schedules a sequence of 'jobs'. each job is
|
||||
// a function returning a promise. The queue waits for each job to finish
|
||||
// before launching the next. Each job can also be scheduled with a delay.
|
||||
// the is used to prevent parallel requests to the proxy.
|
||||
|
||||
var JobQueue = function(){
|
||||
var queue = [];
|
||||
var running = false;
|
||||
var scheduled_end_time = 0;
|
||||
var end_of_queue = Promise.resolve();
|
||||
var stoprepeat = false;
|
||||
|
||||
var run = function () {
|
||||
var runNextJob = function () {
|
||||
if (queue.length === 0) {
|
||||
running = false;
|
||||
scheduled_end_time = 0;
|
||||
return Promise.resolve();
|
||||
}
|
||||
running = true;
|
||||
var job = queue[0];
|
||||
if (!job.opts.repeat || stoprepeat) {
|
||||
queue.shift();
|
||||
stoprepeat = false;
|
||||
}
|
||||
|
||||
// the time scheduled for this job
|
||||
scheduled_end_time = (new Date()).getTime() + (job.opts.duration || 0);
|
||||
|
||||
// we run the job and put in prom when it finishes
|
||||
var prom = job.fun() || Promise.resolve();
|
||||
|
||||
var always = function () {
|
||||
// we run the next job after the scheduled_end_time, even if it finishes before
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(
|
||||
resolve,
|
||||
Math.max(0, scheduled_end_time - (new Date()).getTime())
|
||||
);
|
||||
});
|
||||
};
|
||||
// we don't care if a job fails ...
|
||||
return prom.then(always, always).then(runNextJob);
|
||||
};
|
||||
|
||||
if (!running) {
|
||||
end_of_queue = runNextJob();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a job to the schedule.
|
||||
*
|
||||
* @param {function} fun must return a promise
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.duration] the job is guaranteed to finish no quicker than this (milisec)
|
||||
* @param {boolean} [opts.repeat] if true, the job will be endlessly repeated
|
||||
* @param {boolean} [opts.important] if true, the scheduled job cannot be canceled by a queue.clear()
|
||||
*/
|
||||
this.schedule = function (fun, opts) {
|
||||
queue.push({fun:fun, opts:opts || {}});
|
||||
if(!running){
|
||||
run();
|
||||
}
|
||||
};
|
||||
|
||||
// remove all jobs from the schedule (except the ones marked as important)
|
||||
this.clear = function(){
|
||||
queue = _.filter(queue,function(job){return job.opts.important === true;});
|
||||
};
|
||||
|
||||
// end the repetition of the current job
|
||||
this.stoprepeat = function(){
|
||||
stoprepeat = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when all scheduled jobs have been run.
|
||||
* (jobs added after the call to this method are considered as well)
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
this.finished = function () {
|
||||
return end_of_queue;
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
|
||||
// this object interfaces with the local proxy to communicate to the various hardware devices
|
||||
// connected to the Point of Sale. As the communication only goes from the POS to the proxy,
|
||||
// methods are used both to signal an event, and to fetch information.
|
||||
|
||||
var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
|
||||
init: function(options){
|
||||
mixins.PropertiesMixin.init.call(this);
|
||||
var self = this;
|
||||
options = options || {};
|
||||
|
||||
this.env = options.env;
|
||||
|
||||
this.weighing = false;
|
||||
this.debug_weight = 0;
|
||||
this.use_debug_weight = false;
|
||||
|
||||
this.paying = false;
|
||||
|
||||
this.notifications = {};
|
||||
this.bypass_proxy = false;
|
||||
|
||||
this.connection = null;
|
||||
this.host = '';
|
||||
this.keptalive = false;
|
||||
|
||||
this.set('status',{});
|
||||
|
||||
this.set_connection_status('disconnected');
|
||||
|
||||
this.on('change:status',this,function(eh,status){
|
||||
status = status.newValue;
|
||||
if(status.status === 'connected' && self.printer) {
|
||||
self.printer.print_receipt();
|
||||
}
|
||||
});
|
||||
|
||||
this.posbox_supports_display = true;
|
||||
|
||||
window.hw_proxy = this;
|
||||
},
|
||||
set_pos: function(pos) {
|
||||
this.setParent(pos);
|
||||
this.pos = pos;
|
||||
},
|
||||
set_connection_status: function(status, drivers, msg=''){
|
||||
var oldstatus = this.get('status');
|
||||
var newstatus = {};
|
||||
newstatus.status = status;
|
||||
newstatus.drivers = status === 'disconnected' ? {} : oldstatus.drivers;
|
||||
newstatus.drivers = drivers ? drivers : newstatus.drivers;
|
||||
newstatus.msg = msg;
|
||||
this.set('status',newstatus);
|
||||
},
|
||||
disconnect: function(){
|
||||
if(this.get('status').status !== 'disconnected'){
|
||||
this.connection.destroy();
|
||||
this.set_connection_status('disconnected');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Connects to the specified url.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {Promise}
|
||||
*/
|
||||
connect: function(url){
|
||||
var self = this;
|
||||
this.connection = new Session(undefined,url, { use_cors: true});
|
||||
this.host = url;
|
||||
if (this.pos.config.iface_print_via_proxy) {
|
||||
this.connect_to_printer();
|
||||
}
|
||||
this.set_connection_status('connecting',{});
|
||||
|
||||
return this.message('handshake').then(function(response){
|
||||
if(response){
|
||||
self.set_connection_status('connected');
|
||||
localStorage.hw_proxy_url = url;
|
||||
self.keepalive();
|
||||
}else{
|
||||
self.set_connection_status('disconnected');
|
||||
console.error('Connection refused by the Proxy');
|
||||
}
|
||||
},function(){
|
||||
self.set_connection_status('disconnected');
|
||||
console.error('Could not connect to the Proxy');
|
||||
});
|
||||
},
|
||||
|
||||
connect_to_printer: function () {
|
||||
this.printer = new Printer(this.host, this.pos);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find a proxy and connects to it.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.force_ip] only try to connect to the specified ip.
|
||||
* @param {string} [options.port] @see find_proxy
|
||||
* @param {function} [options.progress] @see find_proxy
|
||||
* @returns {Promise}
|
||||
*/
|
||||
autoconnect: function (options) {
|
||||
var self = this;
|
||||
this.set_connection_status('connecting',{});
|
||||
if (this.pos.config.iface_print_via_proxy) {
|
||||
this.connect_to_printer();
|
||||
}
|
||||
var found_url = new Promise(function () {});
|
||||
|
||||
if (options.force_ip) {
|
||||
// if the ip is forced by server config, bailout on fail
|
||||
found_url = this.try_hard_to_connect(options.force_ip, options);
|
||||
} else if (localStorage.hw_proxy_url) {
|
||||
// try harder when we remember a good proxy url
|
||||
found_url = this.try_hard_to_connect(localStorage.hw_proxy_url, options)
|
||||
.catch(function () {
|
||||
if (window.location.protocol != 'https:') {
|
||||
return self.find_proxy(options);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// just find something quick
|
||||
if (window.location.protocol != 'https:'){
|
||||
found_url = this.find_proxy(options);
|
||||
}
|
||||
}
|
||||
|
||||
var successProm = found_url.then(function (url) {
|
||||
return self.connect(url);
|
||||
});
|
||||
|
||||
successProm.catch(function () {
|
||||
self.set_connection_status('disconnected');
|
||||
});
|
||||
|
||||
return successProm;
|
||||
},
|
||||
|
||||
// starts a loop that updates the connection status
|
||||
keepalive: function () {
|
||||
var self = this;
|
||||
|
||||
function status(){
|
||||
var always = function () {
|
||||
setTimeout(status, 5000);
|
||||
};
|
||||
self.connection.rpc('/hw_proxy/status_json',{},{shadow: true, timeout:2500})
|
||||
.then(function (driver_status) {
|
||||
self.set_connection_status('connected',driver_status);
|
||||
}, function () {
|
||||
if(self.get('status').status !== 'connecting'){
|
||||
self.set_connection_status('disconnected');
|
||||
}
|
||||
}).then(always, always);
|
||||
}
|
||||
|
||||
if (!this.keptalive) {
|
||||
this.keptalive = true;
|
||||
status();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {Object} [params]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
message : function (name, params) {
|
||||
var callbacks = this.notifications[name] || [];
|
||||
for (var i = 0; i < callbacks.length; i++) {
|
||||
callbacks[i](params);
|
||||
}
|
||||
if (this.get('status').status !== 'disconnected') {
|
||||
return this.connection.rpc('/hw_proxy/' + name, params || {}, {shadow: true});
|
||||
} else {
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tries several time to connect to a known proxy url.
|
||||
*
|
||||
* @param {*} url
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.port=8069] what port to listen to
|
||||
* @returns {Promise<string|Array>}
|
||||
*/
|
||||
try_hard_to_connect: function (url, options) {
|
||||
options = options || {};
|
||||
var protocol = window.location.protocol;
|
||||
var port = ( !options.port && protocol == "https:") ? ':443' : ':' + (options.port || '8069');
|
||||
|
||||
this.set_connection_status('connecting');
|
||||
|
||||
if(url.indexOf('//') < 0){
|
||||
url = protocol + '//' + url;
|
||||
}
|
||||
|
||||
if(url.indexOf(':',5) < 0){
|
||||
url = url + port;
|
||||
}
|
||||
|
||||
// try real hard to connect to url, with a 1sec timeout and up to 'retries' retries
|
||||
function try_real_hard_to_connect(url, retries) {
|
||||
return Promise.resolve(
|
||||
$.ajax({
|
||||
url: url + '/hw_proxy/hello',
|
||||
method: 'GET',
|
||||
timeout: 1000,
|
||||
})
|
||||
.then(function () {
|
||||
return Promise.resolve(url);
|
||||
}, function (resp) {
|
||||
if (retries > 0) {
|
||||
return try_real_hard_to_connect(url, retries-1);
|
||||
} else {
|
||||
return Promise.reject([resp.statusText, url]);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return try_real_hard_to_connect(url, 3);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns as a promise a valid host url that can be used as proxy.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.port] what port to listen to (default 8069)
|
||||
* @param {function} [options.progress] callback for search progress ( fac in [0,1] )
|
||||
* @returns {Promise<string>} will be resolved with the proxy valid url
|
||||
*/
|
||||
find_proxy: function(options){
|
||||
options = options || {};
|
||||
var self = this;
|
||||
var port = ':' + (options.port || '8069');
|
||||
var urls = [];
|
||||
var found = false;
|
||||
var parallel = 8;
|
||||
var threads = [];
|
||||
var progress = 0;
|
||||
|
||||
|
||||
urls.push('http://localhost'+port);
|
||||
for(var i = 0; i < 256; i++){
|
||||
urls.push('http://192.168.0.'+i+port);
|
||||
urls.push('http://192.168.1.'+i+port);
|
||||
urls.push('http://10.0.0.'+i+port);
|
||||
}
|
||||
|
||||
var prog_inc = 1/urls.length;
|
||||
|
||||
function update_progress(){
|
||||
progress = found ? 1 : progress + prog_inc;
|
||||
if(options.progress){
|
||||
options.progress(progress);
|
||||
}
|
||||
}
|
||||
|
||||
function thread () {
|
||||
var url = urls.shift();
|
||||
|
||||
if (!url || found || !self.searching_for_proxy) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
$.ajax({
|
||||
url: url + '/hw_proxy/hello',
|
||||
method: 'GET',
|
||||
timeout: 400,
|
||||
}).then(function () {
|
||||
found = true;
|
||||
update_progress();
|
||||
return Promise.resolve(url);
|
||||
}, function () {
|
||||
update_progress();
|
||||
return thread();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.searching_for_proxy = true;
|
||||
|
||||
var len = Math.min(parallel, urls.length);
|
||||
for(i = 0; i < len; i++){
|
||||
threads.push(thread());
|
||||
}
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
Promise.all(threads).then(function(results){
|
||||
var urls = [];
|
||||
for(var i = 0; i < results.length; i++){
|
||||
if(results[i]){
|
||||
urls.push(results[i]);
|
||||
}
|
||||
}
|
||||
resolve(urls[0]);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
stop_searching: function(){
|
||||
this.searching_for_proxy = false;
|
||||
this.set_connection_status('disconnected');
|
||||
},
|
||||
|
||||
// this allows the client to be notified when a proxy call is made. The notification
|
||||
// callback will be executed with the same arguments as the proxy call
|
||||
add_notification: function(name, callback){
|
||||
if(!this.notifications[name]){
|
||||
this.notifications[name] = [];
|
||||
}
|
||||
this.notifications[name].push(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the weight on the scale.
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
scale_read: function () {
|
||||
var self = this;
|
||||
if (self.use_debug_weight) {
|
||||
return Promise.resolve({weight:this.debug_weight, unit:'Kg', info:'ok'});
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
self.message('scale_read',{})
|
||||
.then(function (weight) {
|
||||
resolve(weight);
|
||||
}, function () { //failed to read weight
|
||||
resolve({weight:0.0, unit:'Kg', info:'ok'});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// sets a custom weight, ignoring the proxy returned value.
|
||||
debug_set_weight: function(kg){
|
||||
this.use_debug_weight = true;
|
||||
this.debug_weight = kg;
|
||||
},
|
||||
|
||||
// resets the custom weight and re-enable listening to the proxy for weight values
|
||||
debug_reset_weight: function(){
|
||||
this.use_debug_weight = false;
|
||||
this.debug_weight = 0;
|
||||
},
|
||||
|
||||
update_customer_facing_display: function(html) {
|
||||
if (this.posbox_supports_display && this.get('status').status == 'connected') {
|
||||
return this.message('customer_facing_display',
|
||||
{ html: html },
|
||||
{ timeout: 5000 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @returns {Promise}
|
||||
*/
|
||||
take_ownership_over_customer_screen: function(html) {
|
||||
return this.message("take_control", { html: html });
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns {Promise}
|
||||
*/
|
||||
test_ownership_of_customer_screen: function() {
|
||||
if (this.connection) {
|
||||
return this.message("test_ownership", {});
|
||||
}
|
||||
return Promise.reject({abort: true});
|
||||
},
|
||||
|
||||
// asks the proxy to log some information, as with the debug.log you can provide several arguments.
|
||||
log: function(){
|
||||
return this.message('log',{'arguments': _.toArray(arguments)});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return {
|
||||
JobQueue: JobQueue,
|
||||
ProxyDevice: ProxyDevice,
|
||||
};
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,95 @@
|
|||
odoo.define('point_of_sale.PaymentInterface', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
/**
|
||||
* Implement this interface to support a new payment method in the POS:
|
||||
*
|
||||
* var PaymentInterface = require('point_of_sale.PaymentInterface');
|
||||
* var MyPayment = PaymentInterface.extend({
|
||||
* ...
|
||||
* })
|
||||
*
|
||||
* To connect the interface to the right payment methods register it:
|
||||
*
|
||||
* var models = require('point_of_sale.models');
|
||||
* models.register_payment_method('my_payment', MyPayment);
|
||||
*
|
||||
* my_payment is the technical name of the added selection in
|
||||
* use_payment_terminal.
|
||||
*
|
||||
* If necessary new fields can be loaded on any model:
|
||||
* by overriding the loader_params of the models in the back end
|
||||
* in the `pos.session` model
|
||||
*/
|
||||
var PaymentInterface = core.Class.extend({
|
||||
init: function (pos, payment_method) {
|
||||
this.pos = pos;
|
||||
this.payment_method = payment_method;
|
||||
this.supports_reversals = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Call this function to enable UI elements that allow a user to
|
||||
* reverse a payment. This requires that you implement
|
||||
* send_payment_reversal.
|
||||
*/
|
||||
enable_reversals: function () {
|
||||
this.supports_reversals = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a user clicks the "Send" button in the
|
||||
* interface. This should initiate a payment request and return a
|
||||
* Promise that resolves when the final status of the payment line
|
||||
* is set with set_payment_status.
|
||||
*
|
||||
* For successful transactions set_receipt_info() should be used
|
||||
* to set info that should to be printed on the receipt. You
|
||||
* should also set card_type and transaction_id on the line for
|
||||
* successful transactions.
|
||||
*
|
||||
* @param {string} cid - The id of the paymentline
|
||||
* @returns {Promise} resolved with a boolean that is false when
|
||||
* the payment should be retried. Rejected when the status of the
|
||||
* paymentline will be manually updated.
|
||||
*/
|
||||
send_payment_request: function (cid) {},
|
||||
|
||||
/**
|
||||
* Called when a user removes a payment line that's still waiting
|
||||
* on send_payment_request to complete. Should execute some
|
||||
* request to ensure the current payment request is
|
||||
* cancelled. This is not to refund payments, only to cancel
|
||||
* them. The payment line being cancelled will be deleted
|
||||
* automatically after the returned promise resolves.
|
||||
*
|
||||
* @param {} order - The order of the paymentline
|
||||
* @param {string} cid - The id of the paymentline
|
||||
* @returns {Promise}
|
||||
*/
|
||||
send_payment_cancel: function (order, cid) {},
|
||||
|
||||
/**
|
||||
* This is an optional method. When implementing this make sure to
|
||||
* call enable_reversals() in the constructor of your
|
||||
* interface. This should reverse a previous payment with status
|
||||
* 'done'. The paymentline will be removed based on returned
|
||||
* Promise.
|
||||
*
|
||||
* @param {string} cid - The id of the paymentline
|
||||
* @returns {Promise} returns true if the reversal was successful.
|
||||
*/
|
||||
send_payment_reversal: function (cid) {},
|
||||
|
||||
/**
|
||||
* Called when the payment screen in the POS is closed (by
|
||||
* e.g. clicking the "Back" button). Could be used to cancel in
|
||||
* progress payments.
|
||||
*/
|
||||
close: function () {},
|
||||
});
|
||||
|
||||
return PaymentInterface;
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/* @odoo-module alias=point_of_sale.env */
|
||||
|
||||
// This module is basically web.env but with added fields
|
||||
// that are specific to point_of_sale and extensions.
|
||||
|
||||
import env from 'web.env';
|
||||
import concurrency from 'web.concurrency';
|
||||
import devices from 'point_of_sale.devices';
|
||||
import BarcodeReader from 'point_of_sale.BarcodeReader';
|
||||
|
||||
// Create new env object base on web.env.
|
||||
// Mutating this new object won't affect the original object.
|
||||
let pos_env = Object.create(env);
|
||||
|
||||
pos_env.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy
|
||||
pos_env.proxy = new devices.ProxyDevice({ env: pos_env }); // used to communicate to the hardware devices via a local proxy
|
||||
pos_env.barcode_reader = new BarcodeReader({ env: pos_env, proxy: pos_env.proxy });
|
||||
pos_env.posbus = new owl.EventBus();
|
||||
pos_env.posMutex = new concurrency.Mutex();
|
||||
|
||||
export default pos_env;
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/* global html2canvas */
|
||||
odoo.define('point_of_sale.Printer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Session = require('web.Session');
|
||||
var core = require('web.core');
|
||||
const { Gui } = require('point_of_sale.Gui');
|
||||
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. \n' +
|
||||
'Some browsers don\'t allow HTTP calls from websites to devices in the network (for security reasons). ' +
|
||||
'If it is the case, you will need to follow Odoo\'s documentation for ' +
|
||||
'\'Self-signed certificate for ePOS printers\' and \'Secure connection (HTTPS)\' to solve the issue'
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
Successful() {
|
||||
return new PrintResult({
|
||||
successful: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var PrinterMixin = {
|
||||
init: function (pos) {
|
||||
this.receipt_queue = [];
|
||||
this.printResultGenerator = new PrintResultGenerator();
|
||||
this.pos = pos;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: async function(receipt) {
|
||||
if (receipt) {
|
||||
this.receipt_queue.push(receipt);
|
||||
}
|
||||
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 || sendPrintResult.result === false) {
|
||||
this.receipt_queue.length = 0;
|
||||
return this.printResultGenerator.IoTResultError(sendPrintResult.printerErrorCode);
|
||||
}
|
||||
}
|
||||
return this.printResultGenerator.Successful();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a jpeg image from a canvas
|
||||
* @param {DOMElement} canvas
|
||||
*/
|
||||
process_canvas: function (canvas) {
|
||||
return canvas.toDataURL('image/jpeg').replace('data:image/jpeg;base64,','');
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the html as an image to print it
|
||||
* @param {String} receipt: The receipt to be printed, in HTML
|
||||
*/
|
||||
htmlToImg: function (receipt) {
|
||||
$('.pos-receipt-print').html(receipt);
|
||||
this.receipt = $('.pos-receipt-print>.pos-receipt');
|
||||
if (this.isEmail) {
|
||||
$('.pos-receipt-print .pos-receipt').css({ 'padding': '15px', 'padding-bottom': '30px'})
|
||||
}
|
||||
// Odoo RTL support automatically flip left into right but html2canvas
|
||||
// won't work as expected if the receipt is aligned to the right of the
|
||||
// screen so we need to flip it back.
|
||||
this.receipt.parent().css({ left: 0, right: 'auto' });
|
||||
return html2canvas(this.receipt[0], {
|
||||
height: Math.ceil(this.receipt.outerHeight() + this.receipt.offset().top),
|
||||
width: Math.ceil(this.receipt.outerWidth() + 2 * this.receipt.offset().left),
|
||||
scale: 1,
|
||||
}).then(canvas => {
|
||||
$('.pos-receipt-print').empty();
|
||||
return this.process_canvas(canvas);
|
||||
});
|
||||
},
|
||||
|
||||
_onIoTActionResult: function (data){
|
||||
if (this.pos && (data === false || data.result === false)) {
|
||||
Gui.showPopup('ErrorPopup',{
|
||||
'title': _t('Connection to the printer failed'),
|
||||
'body': _t('Please check if the printer is still connected.'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_onIoTActionFail: function () {
|
||||
if (this.pos) {
|
||||
Gui.showPopup('ErrorPopup',{
|
||||
'title': _t('Connection to IoT Box failed'),
|
||||
'body': _t('Please check if the IoT Box is still connected.'),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var Printer = core.Class.extend(PrinterMixin, {
|
||||
init: function (url, pos) {
|
||||
PrinterMixin.init.call(this, pos);
|
||||
this.connection = new Session(undefined, url || 'http://localhost:8069', { use_cors: true});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends a command to the connected proxy to open the cashbox
|
||||
* (the physical box where you store the cash). Updates the status of
|
||||
* the printer with the answer from the proxy.
|
||||
*/
|
||||
open_cashbox: function () {
|
||||
var self = this;
|
||||
return this.connection.rpc('/hw_proxy/default_printer_action', {
|
||||
data: {
|
||||
action: 'cashbox'
|
||||
}
|
||||
}).then(self._onIoTActionResult.bind(self))
|
||||
.guardedCatch(self._onIoTActionFail.bind(self));
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends the printing command the connected proxy
|
||||
* @param {String} img : The receipt to be printed, as an image
|
||||
*/
|
||||
send_printing_job: function (img) {
|
||||
return this.connection.rpc('/hw_proxy/default_printer_action', {
|
||||
data: {
|
||||
action: 'print_receipt',
|
||||
receipt: img,
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
PrinterMixin: PrinterMixin,
|
||||
Printer: Printer,
|
||||
PrintResult,
|
||||
PrintResultGenerator,
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
odoo.define('point_of_sale.utils', function (require) {
|
||||
'use strict';
|
||||
|
||||
const { ConnectionAbortedError, ConnectionLostError } = require('@web/core/network/rpc_service');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This global variable is used by nextFrame to store the timer and
|
||||
* be able to cancel it before another request for animation frame.
|
||||
*/
|
||||
let timer = null;
|
||||
|
||||
/**
|
||||
* Wait for the next animation frame to finish.
|
||||
*/
|
||||
const nextFrame = () => {
|
||||
return new Promise((resolve) => {
|
||||
cancelAnimationFrame(timer);
|
||||
timer = requestAnimationFrame(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function isConnectionError(error) {
|
||||
const _error = identifyError(error);
|
||||
return _error instanceof ConnectionAbortedError || _error instanceof ConnectionLostError;
|
||||
}
|
||||
|
||||
function identifyError(error) {
|
||||
if (!error) return error;
|
||||
let errorToHandle;
|
||||
if (error.legacy) {
|
||||
// error.message is either RPCError or ConnectionLostError
|
||||
errorToHandle = error.message;
|
||||
} else if (error.event && error.event.type == 'abort') {
|
||||
// Check if there is event and if the event type is abort.
|
||||
// If so, then it's supposed to be a ConnectionAbortedError,
|
||||
// however, it was stripped in the patch of rpc in `mapLegacyEnvToWowlEnv`.
|
||||
// We recreate the error object here so that in the actual handler,
|
||||
// ConnectionAbortedError and ConnectionLostError are handled properly.
|
||||
errorToHandle = new ConnectionAbortedError(error.message);
|
||||
} else if (error instanceof Error) {
|
||||
errorToHandle = error;
|
||||
}
|
||||
return errorToHandle || error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a batched version of a callback so that all calls to it in the same
|
||||
* microtick will only call the original callback once.
|
||||
*
|
||||
* @param callback the callback to batch
|
||||
* @returns a batched version of the original callback
|
||||
*/
|
||||
function batched(callback) {
|
||||
let called = false;
|
||||
return async () => {
|
||||
// This await blocks all calls to the callback here, then releases them sequentially
|
||||
// in the next microtick. This line decides the granularity of the batch.
|
||||
await Promise.resolve();
|
||||
if (!called) {
|
||||
called = true;
|
||||
// so that only the first call to the batched function calls the original callback.
|
||||
// Schedule this before calling the callback so that calls to the batched function
|
||||
// within the callback will proceed only after resetting called to false, and have
|
||||
// a chance to execute the callback again
|
||||
Promise.resolve().then(() => called = false);
|
||||
callback();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* comes from o_spreadsheet.js
|
||||
* https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
|
||||
* */
|
||||
function uuidv4() {
|
||||
// mainly for jest and other browsers that do not have the crypto functionality
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
let r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
return { getFileAsText, nextFrame, identifyError, isConnectionError, batched, uuidv4 };
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue