Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,21 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
function runPoSJSTests({ env }) {
return {
type: "item",
description: env._t("Run Point of Sale JS Tests"),
callback: () => {
env.services.action.doAction({
name: env._t("JS Tests"),
target: "new",
type: "ir.actions.act_url",
url: "/pos/ui/tests?debug=assets",
});
},
sequence: 35,
};
}
registry.category("debug").category("default").add("point_of_sale.runPoSJSTests", runPoSJSTests);

View file

@ -0,0 +1,30 @@
odoo.define('point_of_sale.tour', function(require) {
"use strict";
const {_t} = require('web.core');
const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
tour.register('point_of_sale_tour', {
url: "/web",
rainbowMan: false,
sequence: 45,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]',
content: Markup(_t("Ready to launch your <b>point of sale</b>?")),
width: 215,
position: 'right',
edition: 'community'
}, {
trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]',
content: Markup(_t("Ready to launch your <b>point of sale</b>?")),
width: 215,
position: 'bottom',
edition: 'enterprise'
}, {
trigger: ".o_pos_kanban button.oe_kanban_action_button",
content: Markup(_t("<p>Ready to have a look at the <b>POS Interface</b>? Let's start our first session.</p>")),
position: "bottom"
}]);
});

View file

@ -0,0 +1,6 @@
.pos .screen .content-cell{
height: 100%;
}
.pos .subwindow .subwindow-container{
height: 100%;
}

View file

@ -0,0 +1,726 @@
@keyframes item_in {
0% {
opacity: 0;
margin-top: -30px;
}
50% {
margin-top: 0;
}
100% {
opacity: 1;
}
}
@-webkit-keyframes item_in {
0% {
opacity: 0;
margin-top: -30px;
}
50% {
margin-top: 0;
}
100% {
opacity: 1;
}
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
font-smooth: always;
}
body .pos-customer_facing_display {
background-color: #f6f6f6;
font-size: 2vw;
font-family: Futura, HelveticaNeue, Helvetica, Arial, "Lucida Grande", sans-serif;
font-weight: 300;
width: 100%;
height: 100%;
padding: 0;
-webkit-display: flex;
-moz-display: flex;
-ms-display: flex;
-o-display: flex;
display: flex;
-webkit-flex-direction: row;
-moz-flex-direction: row;
-ms-flex-direction: row;
-o-flex-direction: row;
flex-direction: row;
}
body .pos-customer_facing_display .pos-customer_products,
body .pos-customer_facing_display .pos-payment_info {
height: 100%;
padding: 2%;
-webkit-display: flex;
-moz-display: flex;
-ms-display: flex;
-o-display: flex;
display: flex;
-webkit-flex-direction: column;
-moz-flex-direction: column;
-ms-flex-direction: column;
-o-flex-direction: column;
flex-direction: column;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-moz-box-flex: 1;
-ms-flex-positive: 1;
flex-grow: 1;
}
body .pos-customer_facing_display .pos_orderlines {
width: 100%;
height: 100%;
-webkit-display: flex;
-moz-display: flex;
-ms-display: flex;
-o-display: flex;
display: flex;
-webkit-flex-direction: column;
-moz-flex-direction: column;
-ms-flex-direction: column;
-o-flex-direction: column;
flex-direction: column;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_list {
overflow-y: scroll;
padding-right: 1.5vw;
position: relative;
height: 100%;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item {
margin-bottom: 1vw;
padding: 1%;
border-radius: 0.3vw;
height: auto;
-webkit-box-flex: 0 1 auto;
-webkit-flex: 0 1 auto;
-moz-box-flex: 0 1 auto;
-ms-flex: 0 1 auto;
flex: 0 1 auto;
-webkit-display: flex;
-moz-display: flex;
-ms-display: flex;
-o-display: flex;
display: flex;
-webkit-flex-direction: row;
-moz-flex-direction: row;
-ms-flex-direction: row;
-o-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-webkit-align-items: center;
-moz-box-align: center;
-ms-flex-align: center;
-ms-grid-row-align: center;
align-items: center;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item:last-of-type {
animation: item_in 1s ease;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header {
background-color: transparent;
box-shadow: none;
animation: none;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div, body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child {
border-left-width: 0;
text-align: center;
font-size: 70%;
font-weight: normal;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child {
text-align: left;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div {
width: 5%;
text-align: left;
margin-right: 4%;
font-size: 80%;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-moz-box-flex: 1;
-ms-flex-positive: 1;
flex-grow: 1;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:first-child {
margin-right: 2%;
-webkit-box-flex: 1 1 1%;
-webkit-flex: 1 1 1%;
-moz-box-flex: 1 1 1%;
-ms-flex: 1 1 1%;
flex: 1 1 1%;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:nth-child(2) {
width: 40%;
border-left: 1px solid;
padding-left: 2%;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:nth-child(3) {
text-align: center;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div:last-child {
margin-right: 0;
font-weight: bold;
}
body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item > div div {
background-position: center;
background-size: cover;
padding-top: 75%;
display: block;
}
body .pos-customer_facing_display .pos-payment_info {
max-width: 30%;
padding: 2% 2% 1% 2%;
-webkit-flex-direction: column;
-moz-flex-direction: column;
-ms-flex-direction: column;
-o-flex-direction: column;
flex-direction: column;
-webkit-box-pack: space-between;
-webkit-justify-content: space-between;
-moz-box-pack: space-between;
-ms-flex-pack: space-between;
justify-content: space-between;
}
body .pos-customer_facing_display .pos-payment_info .pos-adv,
body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
background-position: center top;
background-size: contain;
background-repeat: no-repeat;
}
body .pos-customer_facing_display .pos-payment_info .pos-adv[style*="url(http://placehold.it"],
body .pos-customer_facing_display .pos-payment_info .pos-company_logo[style*="url(http://placehold.it"] {
background-color: #ccc;
}
body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
background-image: url(/logo);
margin-bottom: 10%;
-webkit-box-flex: 0 0 20%;
-webkit-flex: 0 0 20%;
-moz-box-flex: 0 0 20%;
-ms-flex: 0 0 20%;
flex: 0 0 20%;
}
body .pos-customer_facing_display .pos-payment_info .pos-adv {
margin-bottom: 5%;
border-bottom: 10px solid transparent;
box-shadow: 0 1px rgba(246, 246, 246, 0.2);
-webkit-box-flex: 1 1 60%;
-webkit-flex: 1 1 60%;
-moz-box-flex: 1 1 60%;
-ms-flex: 1 1 60%;
flex: 1 1 60%;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total,
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines {
-webkit-flex-direction: row;
-moz-flex-direction: row;
-ms-flex-direction: row;
-o-flex-direction: row;
flex-direction: row;
-webkit-display: flex;
-moz-display: flex;
-ms-display: flex;
-o-display: flex;
display: flex;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-box-pack: space-between;
-webkit-justify-content: space-between;
-moz-box-pack: space-between;
-ms-flex-pack: space-between;
justify-content: space-between;
-webkit-box-align: baseline;
-webkit-align-items: baseline;
-moz-box-align: baseline;
-ms-flex-align: baseline;
-ms-grid-row-align: baseline;
align-items: baseline;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total > div,
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines > div {
-webkit-box-flex: 1 0 48%;
-webkit-flex: 1 0 48%;
-moz-box-flex: 1 0 48%;
-ms-flex: 1 0 48%;
flex: 1 0 48%;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total > div:nth-child(even),
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines > div:nth-child(even) {
font-weight: bold;
font-size: 120%;
margin-right: 0;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total {
font-size: 2vw;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines {
margin-top: 2%;
font-size: 1.5vw;
line-height: 1.3;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container {
background-image: url(/web/static/img/logo_inverse_white_206px.png);
background-position: right top;
background-size: contain;
background-repeat: no-repeat;
height: 1.5vw;
margin-top: 10%;
}
@media all and (orientation: portrait) {
body .pos-customer_facing_display {
font-size: 2vh;
height: 100%;
-webkit-flex-direction: column;
-moz-flex-direction: column;
-ms-flex-direction: column;
-o-flex-direction: column;
flex-direction: column;
}
body .pos-customer_facing_display:before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 17vh;
}
body .pos-customer_facing_display .pos-payment_info .pos-adv {
position: fixed;
top: 0;
left: 0;
height: 15vh;
width: 99vw;
margin: 0.5vh;
border-width: 0;
-webkit-display: flex;
-moz-display: flex;
-ms-display: flex;
-o-display: flex;
display: flex;
}
body .pos-customer_facing_display.pos-js_no_ADV:before {
display: none;
}
body .pos-customer_facing_display.pos-js_no_ADV .pos-customer_products {
padding-top: 0;
}
body .pos-customer_facing_display .pos-customer_products {
padding-top: 17vh;
height: 72vw;
overflow: hidden;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines {
-webkit-box-flex: 1 0 auto;
-webkit-flex: 1 0 auto;
-moz-box-flex: 1 0 auto;
-ms-flex: 1 0 auto;
flex: 1 0 auto;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_item > div:nth-child(2) {
width: 30%;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_item.pos_orderlines_header div {
font-size: 90%;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list {
padding-right: 1.5vh;
height: auto;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item {
box-shadow: 0 0.1vh 0.1vh #dddddd;
margin-bottom: 1vh;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item > div {
font-size: 100%;
}
body .pos-customer_facing_display .pos-payment_info {
max-width: 100%;
overflow: hidden;
padding-top: 0;
min-height: 120px;
-webkit-box-flex: 0 1 23vw;
-webkit-flex: 0 1 23vw;
-moz-box-flex: 0 1 23vw;
-ms-flex: 0 1 23vw;
flex: 0 1 23vw;
-webkit-flex-direction: row;
-moz-flex-direction: row;
-ms-flex-direction: row;
-o-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-webkit-align-items: center;
-moz-box-align: center;
-ms-flex-align: center;
-ms-grid-row-align: center;
align-items: center;
-webkit-box-pack: space-between;
-webkit-justify-content: space-between;
-moz-box-pack: space-between;
-ms-flex-pack: space-between;
justify-content: space-between;
}
body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
margin: 0;
background-position: left center;
margin-right: 5%;
height: 100%;
padding: 0;
-webkit-box-flex: 1 1 20%;
-webkit-flex: 1 1 20%;
-moz-box-flex: 1 1 20%;
-ms-flex: 1 1 20%;
flex: 1 1 20%;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details {
-webkit-box-flex: 0 1 50%;
-webkit-flex: 0 1 50%;
-moz-box-flex: 0 1 50%;
-ms-flex: 0 1 50%;
flex: 0 1 50%;
-webkit-flex-direction: column;
-moz-flex-direction: column;
-ms-flex-direction: column;
-o-flex-direction: column;
flex-direction: column;
min-width: 170px;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total {
font-size: 3vw;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total .pos_total-amount {
font-size: 3.5vw;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-paymentlines {
margin-top: 2%;
font-size: 80%;
line-height: 1.2;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container {
position: absolute;
right: 3%;
bottom: 1%;
}
}
@media all and (orientation: portrait) and (max-width: 340px) {
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list {
padding-right: 0;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_list .pos_orderlines_item > div {
font-size: 70%;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_header > div {
font-size: 60%;
}
body .pos-customer_facing_display .pos-customer_products .pos_orderlines .pos_orderlines_header > div:last-child {
text-align: center;
}
body .pos-customer_facing_display .pos-payment_info .pos-company_logo {
display: none !important;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details {
-webkit-box-flex: 1 0 100%;
-webkit-flex: 1 0 100%;
-moz-box-flex: 1 0 100%;
-ms-flex: 1 0 100%;
flex: 1 0 100%;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total {
font-size: 6vw;
}
body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-total .pos_total-amount {
font-size: 6.5vw;
}
}
body .pos-hidden {
opacity: 0;
}
.pos-palette_01 .pos-payment_info {
background: #3E3E3E;
color: #f6f6f6;
}
.pos-palette_01 .pos-customer_products {
background: #f6f6f6;
color: #585858;
}
.pos-palette_01 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #aaaaaa;
}
.pos-palette_01 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_01:before {
background: #3E3E3E;
}
}
.pos-palette_02 .pos-payment_info {
background: #364152;
color: #e6e7e8;
}
.pos-palette_02 .pos-customer_products {
background: #ecf2f6;
color: #364152;
}
.pos-palette_02 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #364152;
}
.pos-palette_02 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_02:before {
background: #364152;
}
}
.pos-palette_03 .pos-payment_info {
background: #1BA39C;
color: #f6f6f6;
}
.pos-palette_03 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_03 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_03 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_03:before {
background: #1BA39C;
}
}
.pos-palette_04 .pos-payment_info {
background: #0b7b6c;
color: #f6f6f6;
}
.pos-palette_04 .pos-customer_products {
background: #efeeec;
color: #585858;
}
.pos-palette_04 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a9a499;
}
.pos-palette_04 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_04:before {
background: #0b7b6c;
}
}
.pos-palette_05 .pos-payment_info {
background: #E26868;
color: #f6f6f6;
}
.pos-palette_05 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_05 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_05 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_05:before {
background: #E26868;
}
}
.pos-palette_06 .pos-payment_info {
background: #9E373B;
color: #f6f6f6;
}
.pos-palette_06 .pos-customer_products {
background: #f6f6f6;
color: #585858;
}
.pos-palette_06 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #aaaaaa;
}
.pos-palette_06 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_06:before {
background: #9E373B;
}
}
.pos-palette_07 .pos-payment_info {
background: #ce9934;
color: white;
}
.pos-palette_07 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_07 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_07 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_07:before {
background: #ce9934;
}
}
.pos-palette_08 .pos-payment_info {
background: #a48c77;
color: #f6f6f6;
}
.pos-palette_08 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_08 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_08 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_08:before {
background: #a48c77;
}
}
.pos-palette_09 .pos-payment_info {
background: linear-gradient(30deg, #014d43, #127e71);
color: #f6f6f6;
}
.pos-palette_09 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_09 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_09 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_09:before {
background: linear-gradient(30deg, #014d43, #127e71);
}
}
.pos-palette_10 .pos-payment_info {
background: linear-gradient(30deg, #e2316c, #ea4c89);
color: white;
}
.pos-palette_10 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_10 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_10 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_10:before {
background: linear-gradient(30deg, #e2316c, #ea4c89);
}
}
.pos-palette_11 .pos-payment_info {
background: linear-gradient(30deg, #362b3d, #5b4a63);
color: white;
}
.pos-palette_11 .pos-customer_products {
background: #ececec;
color: #585858;
}
.pos-palette_11 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: white;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #a0a0a0;
}
.pos-palette_11 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_11:before {
background: linear-gradient(30deg, #362b3d, #5b4a63);
}
}
.pos-palette_12 .pos-payment_info {
background: #434343;
color: #e6e6e6;
}
.pos-palette_12 .pos-customer_products {
background: #5b5b5b;
color: #bdb9b9;
}
.pos-palette_12 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: #f5f5f5;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #0f0f0f;
}
.pos-palette_12 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_12:before {
background: #434343;
}
}
.pos-palette_13 .pos-payment_info {
background: linear-gradient(30deg, #1a1b1f, #3d3f45);
color: white;
}
.pos-palette_13 .pos-customer_products {
background: #a2a2ab;
color: #f6f6f6;
}
.pos-palette_13 .pos-customer_products .pos_orderlines_list .pos_orderlines_item {
background-color: #f6f6f6;
color: #3E3E3E;
box-shadow: 0 0.1vh 0.1vh #55555f;
}
.pos-palette_13 .pos-customer_products .pos_orderlines_list .pos_orderlines_item div:nth-child(2) {
border-color: rgba(62, 62, 62, 0.3);
}
@media all and (orientation: portrait) {
.pos-palette_13:before {
background: linear-gradient(30deg, #1a1b1f, #3d3f45);
}
}

View file

@ -0,0 +1,36 @@
.pos .opening-cash-control .body{
margin: 40px;
}
.pos .opening-cash-control .opening-cash-section {
display: flex;
justify-content: space-between;
margin: 40px 0;
}
.pos .opening-cash-section .info-title {
font-size: 20px;
font-weight: bold;
}
.pos .opening-cash-section .cash-input-sub-section {
display: flex;
}
.pos .cash-input-sub-section .pos-input {
width: 85px;
}
.pos .cash-input-sub-section .button.icon {
margin: 0;
float: unset;
}
.pos .opening-cash-control .opening-cash-notes {
font-style: italic;
font-weight: 350;
width: calc(100% - 20px); /* textarea has a padding of 10px */
line-height: 20px;
resize: none;
height: 150px;
}

View file

@ -0,0 +1,188 @@
.pos .close-pos-popup {
max-width: 800px !important;
max-height: 800px;
}
.pos .close-pos-popup .body{
margin: 0;
padding: 3% 5%;
}
.pos .close-pos-popup header {
display: flex;
justify-content: space-between;
width: auto;
height: 65px;
background-color: rgb(255, 255, 255);
}
.pos .close-pos-popup .title,
.pos .close-pos-popup .total-orders {
padding: 2.5%;
}
.pos .close-pos-popup .total-orders .amount {
display: inline-block;
}
.notes-container {
display: flex;
justify-content: space-around;
width: 100%;
padding-top: 1%;
}
.pos .close-pos-popup .opening-notes {
color: darkgrey;
text-align: left;
font-size: 16px;
box-sizing: border-box;
min-width: 40%;
padding: 5px 10px;
border-left: solid 3px darkgray;
overflow-y: auto;
word-break: break-word;
}
.pos .close-pos-popup .closing-notes {
font-style: italic;
font-weight: 350;
box-sizing: border-box;
line-height: 20px;
width: 100%;
min-width: 60%;
min-height: 100px;
resize: none;
}
.pos .close-pos-popup .payment-methods-overview {
max-height: 320px;
overflow: auto;
}
.pos .close-pos-popup .payment-methods-overview table {
width: 100%;
text-align: left;
}
.pos .close-pos-popup .payment-methods-overview table tr {
height: 35px
}
.pos .close-pos-popup .flex {
display: flex;
}
.pos .close-pos-popup th:nth-child(1),
.pos .close-pos-popup td:nth-child(1) {
padding-right: 5px;
}
.pos .close-pos-popup th:nth-child(2),
.pos .close-pos-popup td:nth-child(2) {
text-align: right;
padding-right: 5%;
white-space: nowrap;
}
.pos .close-pos-popup th:nth-child(3),
.pos .close-pos-popup td:nth-child(3) {
padding-left: 5px;
}
.pos .close-pos-popup th:nth-child(4),
.pos .close-pos-popup td:nth-child(4) {
text-align: left;
padding-left: 10px;
}
.pos .close-pos-popup .payment-methods-overview table .pos-input {
width: 75%;
text-align: right;
padding-right: 7%;
margin-right: 3%;
}
.pos .close-pos-popup .payment-methods-overview table .warning {
color: red;
font-weight: bold;
}
.pos .close-pos-popup .body .button.icon {
margin: 0;
float: unset;
margin-right: 0 20px 0 20px;
}
.pos .close-pos-popup .payment-methods-overview table .cash-sign {
width: 10px;
margin-right: 5px;
}
.pos .close-pos-popup .payment-methods-overview .cash-overview {
border-left: solid 2px #555555;
}
.pos .close-pos-popup .payment-methods-overview .cash-overview tr td:first-child {
padding-left: 10px;
}
.pos .close-pos-popup .invisible {
visibility: hidden;
}
.pos .close-pos-popup .accept-closing label.disabled {
cursor: default;
color: grey;
}
.pos .close-pos-popup .footer {
box-sizing: border-box;
padding-left: 10px;
}
.pos .close-pos-popup .footer .button {
float: left;
width: 16%;
min-width: 95px;
}
@media screen and (max-width: 768px) {
.pos .close-pos-popup {
overflow-y: auto;
}
.pos .close-pos-popup .footer {
display: flex;
flex-direction: column;
height: unset;
padding-left: 0;
}
.pos .close-pos-popup .footer .button {
width: 90% !important;
margin-left: auto;
margin-right: auto;
}
.pos .close-pos-popup .footer .button:last-of-type {
margin-bottom: 10px;
}
}
.pos .close-pos-popup .footer .small.button {
float: right;
width: 6%;
min-width: 45px;
}
.pos .close-pos-popup .footer .button.disabled {
cursor: default;
background: bottom;
color: unset;
border: solid 1px rgba(60, 60, 60, 0.1);
}
.pos .close-pos-popup .footer .button.disabled:active {
border: solid 1px rgba(60, 60, 60, 0.1);
}

View file

@ -0,0 +1,42 @@
/* Input style used in cash control popups */
.pos .popup .pos-input {
text-align: center;
font-size: 18px;
color: #555555;
background: none;
min-height: 0;
border-radius: unset;
box-shadow: none;
padding: 0;
cursor: pointer;
width: 50px;
margin-right: 15px;
border-bottom: solid 2px;
}
.pos .popup .pos-input:focus {
box-shadow: none ;
border-color: blue;
font-weight: bold;
}
.pos .popup .invalid-cash-input {
color: red;
animation: blink 0.5s linear;
animation-iteration-count: 2;
border: 1px solid red;
box-shadow: none !important;
}
.pos .popup .invalid-cash-input:focus {
border-color: red;
}
@keyframes blink {
0%, 100% {
border-color: red;
}
50% {
border-color: transparent;
}
}

View file

@ -0,0 +1,36 @@
.pos .popup.money-details {
width: 350px;
max-height: 440px;
}
.pos .money-details.invisible {
display: none;
}
.pos .money-details .body {
max-height: 350px;
}
.pos .money-details .money-details-title {
text-align: left;
margin-bottom: 15px;
font-weight: bold;
}
.pos .money-details .money-details-info {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
overflow: auto;
max-height: 280px;
padding: 0 15px;
}
.pos .money-details .money-details-value {
display: flex;
margin: 10px 0;
}
.pos .money-details .total-section {
font-weight: bold;
}

View file

@ -0,0 +1,186 @@
.product-info-popup {
max-width: 800px !important;
}
.product-info-popup .body {
max-height: 600px;
overflow: auto;
}
.product-info-popup .section-product-info-title {
display: flex;
justify-content: space-between;
}
.product-info-popup .section-product-info-title .global-info-title {
font-size: 32px;
font-weight: bold;
}
.product-info-popup .section-product-info-title div:first-child{
max-width: 60%;
}
.product-info-popup .section-product-info-title .global-info-title.product-name {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-info-popup .subsection-list {
display: flex;
}
.product-info-popup .subsection-list .subsection-list-value {
margin-left: 30px;
}
.product-info-popup .column {
display: flex;
flex-direction: column;
}
.product-info-popup .flex-start {
align-items: flex-start;
}
.product-info-popup .flex-end {
align-items: flex-end;
}
.product-info-popup .section-title {
font-size: 24px;
font-weight: bold;
margin-top: 30px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
white-space: nowrap;
}
.product-info-popup .section-title-line {
width: 100%;
border-top: solid black 3px;
opacity: 25%;
margin: 15px 10px 0 10px;
}
.product-info-popup table {
text-align: left;
}
.product-info-popup .section-financials-body {
display: flex;
justify-content: space-between;
}
.product-info-popup .section-financials-body table {
width: 45%;
}
.product-info-popup .section-financials-body table td {
width: 50%;
}
.product-info-popup .section-inventory-body table td {
padding-right: 15px;
}
.product-info-popup .section-supplier-body table {
width: 50%;
}
.product-info-popup .section-supplier-body table div{
display: flex;
justify-content: space-between;
}
.product-info-popup .table-value {
padding-left: 15px;
}
.product-info-popup .searchable {
color: blue;
cursor: pointer;
}
.product-info-popup .searchable:hover {
font-weight: bold;
}
.product-info-popup .searchable:active {
color: darkblue;
}
.product-info-popup .section-order-body table {
text-align: left;
}
@media screen and (max-width: 768px) {
.product-info-popup .body {
max-height: 70vh;
}
.product-info-popup .section-product-info-title div:first-child{
max-width: 100%;
}
.product-info-popup .section-product-info-title .global-info-title {
font-size: 28px;
}
.product-info-popup .section-product-info-title {
flex-direction: column;
}
.product-info-popup .flex-end {
align-items: unset;
}
.product-info-popup .section-financials-body {
flex-direction: column;
}
.product-info-popup .section-financials-body table {
width: unset;
}
.product-info-popup .mobile-table tr {
display: flex;
flex-direction: column;
text-align: left;
}
.product-info-popup .mobile-table .table-name:before {
content: "- ";
}
.product-info-popup .mobile-table td:not(:nth-child(1)) {
margin-left: 15px;
}
.product-info-popup .mobile-line {
display: flex;
}
.product-info-popup .mobile-line td {
margin: 0 15px;
}
.product-info-popup .section-supplier-body table {
width: 100%;
}
.product-info-popup .section-supplier-body table tr {
align-items: start;
}
.product-info-popup .section-variants-body .table-value {
padding-left: 0;
}
.product-info-popup .button.cancel {
float: unset;
margin: 10px auto;
}
}

View file

@ -0,0 +1,96 @@
.pos-receipt-print {
width: 512px;
height: 0;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
text-align: left;
direction: ltr;
font-size: 27px;
color: #000000;
}
.pos-receipt .pos-receipt-right-align {
float: right;
}
.pos-receipt .pos-receipt-center-align {
text-align: center;
}
.pos-receipt .pos-receipt-left-padding {
padding-left: 2em;
}
.pos-receipt .pos-receipt-logo {
width: 50%;
display: block;
margin: 0 auto;
}
.pos-receipt .pos-receipt-qrcode {
display: block;
margin: 0 auto;
}
.pos-receipt .pos-receipt-contact {
text-align: center;
font-size: 75%;
}
.pos-receipt .pos-receipt-order-data {
text-align: center;
font-size: 75%;
}
.pos-receipt .pos-receipt-amount {
font-size: 125%;
text-align: start;
}
.pos-receipt .pos-receipt-title {
font-weight: bold;
font-size: 125%;
text-align: center;
}
.pos-receipt .pos-receipt-header {
font-size: 125%;
text-align: center;
}
.pos-receipt .pos-order-receipt-cancel {
color: red;
}
.pos-receipt .pos-receipt-customer-note {
word-break: break-all;
}
.pos-payment-terminal-receipt {
text-align: center;
font-size: 75%;
}
.responsive-price {
display: flex;
flex-wrap: wrap;
width: 100%;
}
.responsive-price > .pos-receipt-right-align {
margin-left: auto;
}
.pos-receipt .pos-receipt-taxes {
display: grid;
grid-template-columns: min-content min-content auto auto auto;
width: 100%;
justify-items: end;
}
.pos-receipt-qty-per-price {
/*rtl:ignore*/
direction: ltr;
}

View file

@ -0,0 +1,82 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import Chrome from "point_of_sale.Chrome";
import ProductScreen from "point_of_sale.ProductScreen";
import Registries from "point_of_sale.Registries";
import { PosGlobalState } from "point_of_sale.models";
import { configureGui } from "point_of_sale.Gui";
import { registry } from "@web/core/registry";
import env from "point_of_sale.env";
import { debounce } from "@web/core/utils/timing";
import { batched } from "point_of_sale.utils";
const { Component, reactive, markRaw, useExternalListener, useSubEnv, onWillUnmount, xml } = owl;
export class ChromeAdapter extends Component {
setup() {
this.PosChrome = Registries.Component.get(Chrome);
ProductScreen.sortControlButtons();
const legacyActionManager = useService("legacy_action_manager");
// Instantiate PosGlobalState here to ensure that every extension
// (or class overloads) is taken into consideration.
const pos = PosGlobalState.create({ env: markRaw(env) });
this.batchedCustomerDisplayRender = batched(() => {
reactivePos.send_current_order_to_customer_facing_display();
});
const reactivePos = reactive(pos, this.batchedCustomerDisplayRender);
env.pos = reactivePos;
env.legacyActionManager = legacyActionManager;
// The proxy requires the instance of PosGlobalState to function properly.
env.proxy.set_pos(reactivePos);
// TODO: Should we continue on exposing posmodel as global variable?
// Expose only the reactive version of `pos` when in debug mode.
window.posmodel = pos.debug ? reactivePos : pos;
this.env = env;
this.__owl__.childEnv = env;
useSubEnv({
get isMobile() {
return window.innerWidth <= 768;
},
});
let currentIsMobile = this.env.isMobile;
const updateUI = debounce(() => {
if (this.env.isMobile !== currentIsMobile) {
currentIsMobile = this.env.isMobile;
this.render(true);
}
}, 15);
useExternalListener(window, "resize", updateUI);
onWillUnmount(updateUI.cancel);
}
async configureAndStart(chrome) {
// Add the pos error handler when the chrome component is available.
registry.category('error_handlers').add(
'posErrorHandler',
(env, ...noEnvArgs) => {
if (chrome) {
return chrome.errorHandler(this.env, ...noEnvArgs);
}
return false;
},
{ sequence: 0 }
);
// Little trick to avoid displaying the block ui during the POS models loading
const BlockUiFromRegistry = registry.category("main_components").get("BlockUI");
registry.category("main_components").remove("BlockUI");
configureGui({ component: chrome });
await chrome.start();
registry.category("main_components").add("BlockUI", BlockUiFromRegistry);
// Subscribe to the changes in the models.
this.batchedCustomerDisplayRender();
}
}
ChromeAdapter.template = xml`<t t-component="PosChrome" setupIsDone.bind="configureAndStart"/>`;

View file

@ -0,0 +1,36 @@
/** @odoo-module */
import { startWebClient } from "@web/start";
import { ChromeAdapter } from "@point_of_sale/entry/chrome_adapter";
import Registries from "point_of_sale.Registries";
import { registry } from "@web/core/registry";
const { Component, xml } = owl;
// For consistency's sake, we should trigger"WEB_CLIENT_READY" on the bus when PosApp is mounted
// But we can't since mail and some other poll react on that cue, and we don't want those services started
class PosApp extends Component {
setup() {
this.Components = registry.category("main_components").getEntries();
}
}
PosApp.template = xml`
<body>
<ChromeAdapter />
<div>
<t t-foreach="Components" t-as="C" t-key="C[0]">
<t t-component="C[1].Component" t-props="C[1].props"/>
</t>
</div>
</body>
`;
PosApp.components = { ChromeAdapter };
function startPosApp() {
Registries.Component.freeze();
Registries.Model.freeze();
startWebClient(PosApp);
}
startPosApp();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,199 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg3162"
version="1.1"
inkscape:version="0.48.3.1 r9886"
width="152"
height="152"
sodipodi:docname="ios7-icon.png"
inkscape:export-filename="/home/fva/Code/openerp/point_of_sale/touch-icon-ipad-retina.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<metadata
id="metadata3168">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3166">
<linearGradient
id="linearGradient3944">
<stop
style="stop-color:#483c98;stop-opacity:1;"
offset="0"
id="stop3946" />
<stop
style="stop-color:#8075c9;stop-opacity:1;"
offset="1"
id="stop3948" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3944"
id="linearGradient3950"
x1="116.83051"
y1="0.49999994"
x2="115.35169"
y2="227.45763"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.67555556,0,0,0.67555556,0,-0.67555559)" />
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1111"
id="namedview3164"
showgrid="true"
inkscape:zoom="1"
inkscape:cx="39.575132"
inkscape:cy="237.57664"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg3162">
<inkscape:grid
type="xygrid"
id="grid3942"
empspacing="5"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true" />
</sodipodi:namedview>
<rect
style="fill:url(#linearGradient3950);fill-opacity:1;fill-rule:evenodd;stroke:none"
id="rect3172"
width="152"
height="152"
x="0"
y="-3.9968029e-15"
ry="34.723557" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none"
id="rect3952"
width="5"
height="80"
x="25"
y="37"
ry="1" />
<rect
ry="1"
y="37"
x="35"
height="80"
width="3.0532093"
id="rect3954"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none"
id="rect3956"
width="5.0000033"
height="80"
x="45"
y="37"
ry="1" />
<rect
ry="1"
y="37"
x="54.999996"
height="80"
width="3.0000036"
id="rect3958"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none"
id="rect3960"
width="3.0000036"
height="80"
x="65"
y="37"
ry="1" />
<rect
ry="1"
y="37"
x="75"
height="80"
width="3.0000036"
id="rect3962"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none"
id="rect3964"
width="5.0000057"
height="80"
x="80"
y="37"
ry="1" />
<rect
ry="1"
y="37"
x="90"
height="80"
width="3.0000036"
id="rect3966"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
<rect
ry="1"
y="37"
x="100.02039"
height="80"
width="5.0000057"
id="rect3968"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none"
id="rect3970"
width="3.0000036"
height="80"
x="107"
y="37"
ry="1" />
<rect
ry="1"
y="37"
x="114.99999"
height="80"
width="5.0000057"
id="rect3972"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none"
id="rect3974"
width="3.0532093"
height="80"
x="122"
y="37"
ry="1" />
<rect
style="fill:#f80000;fill-opacity:1;stroke:none"
id="rect3976"
width="2.0000024"
height="110"
x="-103.85593"
y="20"
ry="1.375"
transform="matrix(0,-1,1,0,0,0)" />
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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