diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static/src | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static/src')
193 files changed, 19426 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/src/css/chrome50.css b/addons/point_of_sale/static/src/css/chrome50.css new file mode 100644 index 00000000..67ecddcc --- /dev/null +++ b/addons/point_of_sale/static/src/css/chrome50.css @@ -0,0 +1,6 @@ +.pos .screen .content-cell{ + height: 100%; +} +.pos .subwindow .subwindow-container{ + height: 100%; +}
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/css/customer_facing_display.css b/addons/point_of_sale/static/src/css/customer_facing_display.css new file mode 100644 index 00000000..824d220b --- /dev/null +++ b/addons/point_of_sale/static/src/css/customer_facing_display.css @@ -0,0 +1,730 @@ +@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 { + text-align: right; + margin-top: 10%; + -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; +} +body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .pos-odoo_logo_container img { + max-width: 40px; +} +@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); + } +} diff --git a/addons/point_of_sale/static/src/css/keyboard.css b/addons/point_of_sale/static/src/css/keyboard.css new file mode 100644 index 00000000..ee5fcfe3 --- /dev/null +++ b/addons/point_of_sale/static/src/css/keyboard.css @@ -0,0 +1,153 @@ +/* Onscreen Keyboard http://net.tutsplus.com/tutorials/javascript-ajax/creating-a-keyboard-with-css-and-jquery/ */ + +/*rtl:ignore*/ +.pos .keyboard_frame{ + display: none; + position:absolute; + left: 0; + bottom: 0px; + margin: 0; + padding: 0; + padding-top: 15px; + width: 100%; + height: 0px; /* 235px, animated via jquery */ + background-color: #BBB; + overflow:hidden; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + z-index:10000; +} +.pos .keyboard_frame .close_button{ + height:40px; + width:60px; + text-align:center; + background-color: #DDD; + font-size: 12px; + line-height:40px; + -webkit-border-radius: 5px; + position:absolute; + top:0; + right:15px; + cursor: pointer; +} +/*rtl:ignore*/ +.pos .keyboard li { + float: left; + text-align: center; + background-color: #fff; + border: 1px solid #f0f0f0; + top:0; + cursor: pointer; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + -webkit-transition-property: top, background-color; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: linear; +} +/*rtl:ignore*/ +.pos .keyboard li:active{ + position: relative; + top: 2px; + left: 0px; + border-color: #ddd; + background-color:#e5e5e5; + cursor: pointer; + -webkit-transition-property: top, background-color; + -webkit-transition-duration: 0.1s; + -webkit-transition-timing-function: ease-out; +} +.pos .uppercase { + text-transform: uppercase; +} +.pos .on { + display: none; +} +/*rtl:ignore*/ +.pos .firstitem{ + clear: left; +} +/*rtl:ignore*/ +.pos .keyboard .lastitem { + margin-right: 0 !important; +} + +/* ---- full sized keyboard ---- */ + +.pos .full_keyboard { + list-style: none; + font-size: 14px; + width: 685px; + height: 100%; + margin-left: auto !important; + margin-right: auto !important; +} +/*rtl:ignore*/ +.pos .full_keyboard li{ + margin: 0 5px 5px 0 !important; + width: 40px; + height: 40px; + line-height: 40px; +} +.pos .full_keyboard .tab, .pos .full_keyboard .delete { + width: 70px; +} +.pos .full_keyboard .capslock { + width: 80px; +} +.pos .full_keyboard .return { + width: 77px; +} +.pos .full_keyboard .left-shift { + width: 95px; +} +.pos .full_keyboard .right-shift { + width: 109px; +} + +/*rtl:ignore*/ +.pos .full_keyboard .space { + clear: left; + width: 673px; +} + +/* ---- simplified keyboard ---- */ + +.pos .simple_keyboard { + list-style: none; + font-size: 16px; + width: 555px; + height: 220px; + margin-left: auto !important; + margin-right: auto !important; +} +/*rtl:ignore*/ +.pos .simple_keyboard li{ + margin: 0 5px 5px 0 !important; + width: 49px; + height: 49px; + line-height: 49px; +} +/*rtl:ignore*/ +.pos .simple_keyboard .firstitem.row_asdf{ + margin-left:25px !important; +} +/*rtl:ignore*/ +.pos .simple_keyboard .firstitem.row_zxcv{ + margin-left:55px !important; +} +.pos .simple_keyboard .delete{ + width: 103px; +} +.pos .simple_keyboard .return{ + width: 103px; +} +.pos .simple_keyboard .space{ + width:273px; +} +.pos .simple_keyboard .numlock{ + width:103px; +} diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css new file mode 100644 index 00000000..b124326c --- /dev/null +++ b/addons/point_of_sale/static/src/css/pos.css @@ -0,0 +1,3555 @@ +/* --- Fonts --- */ + +@font-face{ + font-family: 'Inconsolata'; + src: url(../fonts/Inconsolata.otf); +} + +/* --- Styling of OpenERP Elements --- */ +.ui-dialog, .modal-dialog { + background: white; + padding: 10px; + border-radius: 3px; + font-family: sans-serif; + box-shadow: 0px 10px 40px rgba(0,0,0,0.4); + position: absolute; + top: 30px; + height: 400px; + overflow: scroll; +} +.ui-dialog button, .modal-dialog button { + padding: 8px; + min-width: 48px; +} +.ui-dialog .ui-icon-closethick{ + float: right; +} +div.modal.in { + position: absolute; + background: white; + padding: 20px; + box-shadow: 0px 10px 20px black; + border-radius: 3px; + max-width: 600px; + max-height: 400px; + margin-top: -200px; + margin-left: -300px; + top: 50%; + left: 50%; +} + +/* --- Generic Restyling and Resets --- */ + +html { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-family: sans-serif; +} + +table { + border-spacing: 0px; + border-collapse: collapse; +} +td { + padding: 0px; +} + +.oe_hidden{ + display: none !important; +} +.oe_invisible{ + visibility: hidden !important; +} +.clearfix:after { + content:" "; + display: block; + visibility: hidden; + line-height: 0; + height: 0; + clear: both; +} + + +.pos input::-webkit-outer-spin-button, +.pos input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.pos button{ + box-shadow: none; + outline: none; + border: none; + font-family: 'Lato'; +} +.pos button:hover{ + background: default; +} +.pos textarea { + font-family: "Lato"; + font-size: 20px; + color: #444; + padding: 10px; + border-radius: 3px; + border: none; + box-shadow: 0px 0px 0px 1px rgb(220,220,220) inset; +} +.pos textarea:focus { + outline: none; + box-shadow: 0px 0px 0px 3px #6EC89B; +} + + +.pos .oe_hidden{ + display: none !important; +} + +.pos ul, .pos li { + margin: 0; + padding: 0; + list-style-type: none; +} + +.pos { + direction: ltr; + padding: 0; + margin: 0; + background-color: #f0eeee; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + color: #555555; + font-size: 12px; + /* Some elements inside .pos are allowed to be moved/resized. When + * this 'move/resize' event is performed in touch devices, .pos + * element also tries to scroll (as the default action of touchstart+ + * touchmove events). + * Position is fixed to prevent the movement of .pos element during + * the described touch events. + */ + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + text-shadow: none; + overflow: hidden; +} + +/* ********* The black loading screen ********* */ + +.pos .loader{ + background-color: #222; + position:absolute; + left:0px; + top:0px; + width:100%; + height:100%; + z-index: 999; + text-align: center; + font-family: Lato; + color: #555555; +} + +.pos .loader-feedback{ + width: 400px; + height: 160px; + margin: -60px -200px; + position: absolute; + left: 50%; top: 50%; + text-align: center; +} +.pos .loader-feedback h1{ + font-weight: 300; +} +.pos .loader-feedback .progressbar{ + background: rgb(73,73,73); + height: 1px; +} +.pos .loader-feedback .progressbar > .progress{ + height: 100%; + background: white; + width: 0%; + box-shadow: 0px 0px 5px rgba(255,255,255,0.35); +} +.pos .loader-feedback .button{ + display: inline-block; + margin: 25px auto; + line-height: 42px; + padding: 0px 16px; + font-size: 20px; + font-weight: 300; + border: solid 1px; + border-radius: 5px; + cursor: pointer; +} +.pos .loader-feedback .button:active{ + color: #222; + background: #555555; +} +/* ********* Generic Layout Constructs ********* */ + +.pos .window{ + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + display: table; + border: none; + overflow: hidden; +} +.pos .window .subwindow{ + display: table-row; + width: 100%; + height: 100%; +} +.pos .window .subwindow.collapsed{ + height: 0px; +} +.pos .window .subwindow-container .collapsed{ + height: 0px; +} +.pos .subwindow .subwindow-container{ + display: table-cell; + position: relative; +} +/* firefox seems to ignore the relative positionning of the subwindow-container + * putting this inside subwindow-container fixes it. + */ +.pos .subwindow .subwindow-container-fix{ + height: 100%; + position: relative; +} + +.pos .clientlist-screen .window, +.pos .clientlist-screen .full-content .subwindow{ + display: block; +} +.pos .clientlist-screen .full-content .subwindow-container{ + display: block; + height: 100%; +} +.pos .clientlist-screen .full-content .subwindow.collapsed, +.pos .clientlist-screen .full-content .subwindow-container.collapsed{ + height: auto; +} + +/* ---- Scrollers ----- */ + +.pos .scroller-container{ + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; +} +.pos .scroller{ + width: 100%; + height: 100%; + overflow: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.pos .scroller.horizontal{ + overflow-y: hidden; + overflow-x: auto; +} +.pos .scroller-content{ + -webkit-transform: translate3d(0,0,0); +} +.pos .scroller-container ::-webkit-scrollbar{ + width: 10px; + height: 10px; +} +.pos .scroller-container ::-webkit-scrollbar-track{ + background: rgb(224,224,224); + border-left: solid 1px rgb(200,200,200); + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} +.pos .scroller-container ::-webkit-scrollbar-thumb{ + background: rgb(168,168,168); + min-height: 30px; +} + +/* ********* Generic element styling ********* */ + +.pos a { + text-decoration: none; + color: #555555; +} +.pos button, .pos a.button { + display: inline-block; + cursor: pointer; + padding: 4px 10px; + font-size: 11px; + border: 1px solid #cacaca; + background: #e2e2e2; + border-radius: 3px; +} +.pos ul, .pos ol { + padding: 0; + margin: 0; +} +.pos li { + list-style-type: none; +} +.pos .pos-right-align { + text-align: right; +} +.pos .pos-center-align { + text-align: center; +} +.pos .pos-disc-font { + font-size: 12px; + font-style:italic; + color: #808080; +} + +/* ********* The black header bar ********* */ + + +.pos .pos-topheader { + position:absolute; + left:0; + top:0; + width: 100%; + height: 48px; + margin:0; + padding:0; + color: gray; + background: #875A7B; + display: flex; +} + +/* a) The left part of the top-bar */ + +.pos .pos-branding{ + min-width: 503px; + max-width: 503px; + flex-grow: 1; + height:100%; + margin:0; + padding:0; + text-align:left; + line-height:100%; + vertical-align: middle; + display: flex; + justify-content: space-between; +} +.pos .pos-logo { + height: 35px; + margin-left: 10px; + margin-top: 5px; + vertical-align:middle; +} +.pos .pos-branding .username{ + float:right; + color:#DDD; + font-size:16px; + margin-right:32px; + line-height: 48px; + font-style:italic; + cursor: pointer; +} + +.pos .ticket-button { + display: flex; +} + +/* b) The right part of the top-bar */ + +.pos .pos-rightheader { + flex-grow: 1; + height:100%; + display: flex; + overflow : hidden; + overflow-x: auto; +} +.pos .pos-rightheader > * { + border-right: 1px solid #875A7B; +} + +.pos .status-buttons-portal { + flex: 1; +} + +.pos .status-buttons { + display: flex; + justify-content: flex-end; + flex: 1; +} + +.pos .order-button{ + color: #f0f0f0; + display: inline-block; + box-sizing: border-box; + -moz-box-sizing: border-box; + height: 46px; + padding: 4px 8px; + margin: 3px; + margin-bottom: 0px; + margin-right: 2px; + padding-top: 0px; + background: #8b8b8b; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + vertical-align: top; + line-height: 42px; + text-align: center; + box-shadow: 0px -5px 10px -6px rgb(82,82,82) inset; + cursor: pointer; + min-width: 45px; +} + +.pos .order-button:first-child { + margin-left: 0px; +} + +.pos .order-button.selected{ + font-weight: 900; + background: #EEEEEE; + color: rgb(75,75,75); + height: 45px; + border-bottom: solid 1px rgb(196, 196, 196); + box-shadow: none; + -webkit-flex-shrink: 0; + flex-shrink: 0; +} + +.pos .order-button .order-sequence{ + font-size: 16px; + font-weight: 800; + vertical-align: middle; +} +.pos .order-button.selected .order-sequence{ + color: white; + background: black; + display: inline-block; + line-height: 24px; + min-width: 24px; + border-radius: 12px; + margin-right: 4px; + margin-left: -4px; +} + +.pos .order-button.square{ + margin-left:1px; + background: #5c5c5c; + color: rgb(160,160,160); + font-size: 18px; + line-height: 45px; +} +.pos .order-button:not(.square) > .fa { + font-size: 16px; + vertical-align: middle; + margin-right: 4px; +} +.pos .order-button .order-sequence{ + font-size: 16px; + font-weight: 800; +} + +.pos .order-selector { + display: -webkit-flex; + display: flex; + -webkit-flex: 1; + flex: 1; +} +.pos .orders { + display: -webkit-flex; + display: flex; + vertical-align: top; + margin-left: 0px; + overflow: hidden; +} + +/* c) The session buttons */ + +.pos .pos-rightheader .header-button{ + float: right; + height: 48px; + padding-left: 16px; + padding-right: 16px; + border-right: 1px solid #875A7B; + border-left: 1px solid #875A7B; + color: #DDD; + line-height: 48px; + text-align: center; + cursor: pointer; + + -webkit-transition-property: background; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} +.pos .pos-rightheader .header-button:last-child{ + border-left: 1px solid #875A7B; +} +.pos .pos-rightheader .header-button:active{ + background: rgba(0,0,0,0.2); + color:#EEE; +} +.pos .pos-rightheader .header-button.confirm { + background: #359766; + color: white; + font-weight: bold; +} + +/* c) The notifications indicator */ + +.pos .oe_status{ + float:right; + color: rgba(255,255,255,0.4); + padding: 14px; + line-height: 20px; + font-size: 20px; + vertical-align:middle; + font-style: italic; + cursor:pointer; +} +.pos .oe_status.oe_inactive{ + cursor: default; +} +.pos .oe_status .oe_icon{ + display:inline-block; + cursor:pointer; + width:20px; height:16px; + color: white; +} +.pos .oe_status .oe_red, +.pos .oe_icon.oe_red { + color: rgb(197, 52, 0); +} +.pos .oe_status .oe_green, +.pos .oe_icon.oe_green { + color: rgb(94, 185, 55); +} +.pos .oe_status .oe_orange, +.pos .oe_icon.oe_orange { + color: rgb(239, 153, 65); +} +.pos .oe_link_icon{ + cursor:pointer; +} +/* ********* Contains everything below the bar ********* */ + +.pos .pos-content { + width: 100%; + position: absolute; + top: 48px; + bottom: 0; + background: #F0EEEE; +} + +/* ********* The leftpane contains the order, numpad and paypad ********* */ + +.pos .switchpane { + height: 100px; + flex-shrink: 0; + display: flex; +} + +.pos .switchpane .order-info { + flex-grow: 1; + border-top: 1px solid #ebebeb; + padding: 8px 16px; + background-color: #FFFFFF; + color: #6ec89b; +} +.pos .switchpane .order-info h2 { + padding: 0px; + margin: 3px 0px; + font-weight: bold; +} + +.pos .switchpane .btn-switchpane { + background-color: #6ec89b; + border-radius: 0px; + color: #FFFFFF; + font-size: 15px; + font-weight: bold; + flex-grow: 1; + flex-basis: 50%; + padding-bottom: 20px; +} +.pos .switchpane .btn-switchpane h1 { + margin-bottom: 0px; +} + +.pos .switchpane .btn-switchpane.secondary { + background-color: #FFFFFF; + color: #6ec89b; +} + + + +.pos .leftpane { + border-right: solid 3px #787878; + background: #e2e2e2; + height: 100%; + display: flex; + flex-direction: column; + flex-grow: 1; + max-width: 500px; +} + +.pos .leftpane .pads { + border-top: solid 3px rgb(110, 200, 155); +} + +.pos .leftpane .pads .subpads { + display: flex; + flex-direction: row; +} + +/* ********* The control buttons ********* */ + +.pos .control-buttons { + display: -webkit-flex; + display: flex; + -webkit-flex-flow: row wrap; + flex-flow: row wrap; + padding: 8px 16px 0px 11px; + margin-bottom: -6px; +} +.pos .control-button { + -webkit-flex-grow: 1; + flex-grow: 1; + background: #e2e2e2; + border: solid 1px #bfbfbf; + display: inline-block; + line-height: 38px; + min-width: 80px; + text-align: center; + border-radius: 3px; + padding: 0px 10px; + font-size: 18px; + margin-left: 6px; + margin-bottom: 6px; + cursor: pointer; + overflow: hidden; + transition: all linear 150ms; +} +.pos .control-button:hover { + background: #efefef; +} +.pos .control-button:active { + background: black; + color: white; + border-color: black; +} +.pos .control-button .fa{ + margin-right: 4px; +} +.pos .control-button .control-button-number { + color: rgb(226, 226, 226); + background: rgb(85, 85, 85); + display: inline-block; + height: 28px; + vertical-align: middle; + font-weight: bold; + line-height: 28px; + width: 28px; + border-radius: 50%; + text-align: center; + margin-left: -16px; + margin-right: 4px; +} + +.pos .control-button.highlight, +.pos .button.highlight { + background: #6EC89B !important; + border: solid 1px #64AF8A !important; + color: white !important; +} +.pos .control-button.altlight, +.pos .button.altlight { + background: #7F82AC !important; + border: solid 1px #756A99 !important; + color: white !important; +} +.pos .control-button.disabled, +.pos .control-button.disabled:active{ + background: #e2e2e2; + border: solid 1px #BEBEBE; + opacity: 0.5; + cursor: default; + color: inherit; +} + +/* ********* The actionpad (payment, set customer) ********* */ + +.pos .actionpad { + padding: 0; + margin: 16px; + margin-top: 8px; + margin-right: 0; + text-align: center; + vertical-align: top; + border: none; + border-radius: 0; + border-top: 1px solid; + border-left: 1px solid; + border-color: #bfbfbf; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + flex-grow: 1; +} +.pos .actionpad .button { + position: relative; + display: block; + height: 54px; + width: 100%; + font-weight: bold; + vertical-align: middle; + color: #555555; + font-size: 14px; + border-radius: 0; + border: none; + border-right: 1px solid; + border-bottom: 1px solid; + border-color: #bfbfbf; + transition: all 150ms linear; +} +.pos .actionpad .button:hover { + background: #efefef; +} +.pos .actionpad .button:active { + background: black; + border-color: black; + color: white; +} +.pos .actionpad .button:first-child { + border-top-left-radius: 4px; +} +.pos .actionpad .button:last-child { + border-bottom-left-radius: 4px; +} +.pos .actionpad .button.pay { + height: 162px; +} +.pos .actionpad .button.pay .pay-circle { + display: block; + font-size: 32px; + line-height: 54px; + padding-top: 6px; + background: rgb(86, 86, 86); + color: white; + width: 60px; + margin: auto; + border-radius: 30px; + margin-bottom: 10px; +} +.pos .actionpad .button.pay .pay-circle .fa { + position: relative; + top: -1px; + left: 3px; +} + +.pos .actionpad .button.set-customer{ + padding-left: 40px; + padding-right: 40px; +} +.pos .actionpad .button.set-customer.decentered { + padding-left: 40px; + padding-right: 5px; +} +.pos .actionpad .button .fa-user { + position: absolute; + left: 13px; + top: 13px; + margin-right: 8px; + font-size: 18px; + background: rgba(255, 255, 255, 0.5); + line-height: 30px; + width: 30px; + border-radius: 100%; +} + +@media screen and (max-width: 768px) { + .pos .actionpad .button.set-customer{ + padding-left: 0px; + padding-right: 0px; + } + .pos .actionpad .button.set-customer.decentered{ + padding-left: 0px; + padding-right: 0px; + } +} + +/* ********* The Numpad ********* */ + +.pos .numpad { + text-align: center; + width: 216px; + margin: 16px; + margin-top: 8px; + margin-left: 0px; + border: none; + border-radius: 0; + border-top: 1px solid; + border-color: #bfbfbf; + border-top-right-radius: 4px; + min-width: 216px; +} +.pos .numpad button { + float: left/*rtl:ignore*/; /* rtlcss forced to keep ltr */ + height: 54px; + width: 54px; + font-weight: bold; + vertical-align: middle; + color: #555555; + border-radius: 0; + border: none; + border-right: 1px solid; + border-bottom: 1px solid; + border-color: #bfbfbf; + transition: all 150ms linear; +} +.pos .numpad button:hover { + background: #efefef; +} +.pos .numpad button:active { + background: black; + color: white; + border-color: transparent; +} +.pos .numpad button:nth-child(4) { + border-top-right-radius: 4px; +} +.pos .numpad button:last-child { + border-bottom-right-radius: 4px; +} +.pos .input-button { + font-size: 24px; +} +.pos .mode-button { + font-size: 14px; +} +.pos .mode-button.selected-mode { + color: white; + background: #6EC89B; + border-color: transparent; +} +.pos .mode-button.selected-mode:hover { + background: #6EC89B; + color: white; + border-color: transparent; +} +.pos .numpad .disabled-mode, .pos .numpad .disabled-mode:hover { + background: #c7c7c7; + color: #a5a1a1; + cursor: not-allowed; +} + +/* ********* The right pane contains the screens and headers ********* */ + +.pos .rightpane { + height: 100%; + display: flex; + flex-direction: column; + flex-basis: 25%; + flex-grow: 1; +} + +.pos .products-widget { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.pos .product-list-container { + overflow: hidden; + overflow-y: auto; + flex-grow: 1; +} + +.pos .rightpane-header { + padding: 0; + background: #d3d3d3; + text-align: center; + display: flex; + flex-flow: row wrap; +} + +.pos .green-border-bottom { + border-bottom: solid 3px rgb(110, 200, 155); +} + +.pos .grey-border-bottom { + border-bottom: 1px solid #c7c7c7; +} + +/* ********* The product list ********* */ + +.pos .product-list { + padding: 10px; + text-align: left; + -webkit-transform: translate3d(0,0,0); + display: flex; + flex-wrap: wrap; +} + +.pos .product-list-scroller{ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + width:100%; + height:100%; + overflow: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -webkit-transform: translate3d(0,0,0); + +} + +/* a) the product list navigation bar */ + +.pos .breadcrumb{ + float: left; + display: inline-block; + line-height: 48px; + height: 48px; + min-width: 48px; +} +.pos .breadcrumb:last-child { + padding-right: 3px; + border-right: 1px solid #c5c5c5; +} +.pos .breadcrumb-button { + display: inline-block; + padding: 0 9px; + vertical-align: top; + color: #808080; + font-size: 14px; + cursor: pointer; +} +.pos .breadcrumb-button.breadcrumb-home { + line-height: 50px; + font-size: 25px; + text-align: center; +} + +.pos .breadcrumb-arrow{ + width: 28px; +} +.pos .breadcrumb-homeimg { + width: 27px; + margin: 12px 6px; +} + +@media screen and (max-width: 768px) { + .pos .breadcrumb-button.breadcrumb-home { + width: auto; + font-size: 13px; + margin-left: 3px; + } +} + +/* b) the search box */ + +.pos .searchbox { + flex-grow: 1; + position: relative; +} +.pos .searchbox input { + width: 150px; + border: 1px solid #cecbcb; + padding: 10px 20px; + margin: 6px; + background-color: white; + border-radius: 20px; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + font-size: 13px; +} +.pos .searchbox input:focus { + outline: none; + box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset; + color: rgb(153, 153, 255); +} + +.pos .search-clear { + top: 9px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear.left { + left: 11px; + color: #808080; + margin: 6px; +} + +.search-clear.right { + color: #808080; + cursor: pointer; + margin: 6px; +} + +@media screen and (max-width: 768px) { + .search-clear.left { + position: relative; + top: -40px; + left: 5%; + } + + .search-clear.right { + position: relative; + top: -70px; + left: 85%; + } + .pos .searchbox input { + width: 70%; + } + .pos .searchbox { + position: relative; + } +} + +/* c) the categories list */ + +.pos .categories { + position: relative; + border-bottom: solid 3px rgb(110, 200, 155); + flex: 1; +} +.pos .categories h4 { + display: inline-block; + margin: 9px 5px; +} +.pos .category-list-scroller{ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + width:100%; + height:100%; + max-height:40vh; + overflow: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -webkit-transform: translate3d(0,0,0); + +} +.pos .category-list { + text-align: left; + padding: 10px; + background: rgb(229, 229, 229); +} +.pos .category-list.simple { + padding: 0px; + background: #cecece; + display: flex; + flex-flow: row wrap; + flex: 1; +} + + +/* d) the category button */ + +.pos .category-button { + position: relative; + vertical-align: top; + display: inline-block; + font-size: 11px; + margin: 8px !important; + width: 120px; + height:120px; + background:#fff; + border: 1px solid #d7d7d7; + border-radius: 3px; + border-bottom-width: 3px; + cursor: pointer; +} + +.pos .category-simple-button{ + display: flex; + align-items: center; + font-size: 14px; + padding: 5px 12px; + cursor: pointer; + flex: 1; + text-align: left; + background: rgb(229, 229, 229); + border-right: solid 1px #d3d3d3; + border-top: solid 1px #d3d3d3; +} +.pos .category-simple-button:active{ + color: white; + background: black; + + -webkit-transition-property: background, border; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} + + + +.pos .category-button .category-img { + position: relative; + width: 120px; + height: 100px; + text-align: center; + cursor: pointer; +} + +.pos .category-button .category-img img { + max-height: 100px; + max-width: 120px; + vertical-align: middle; +} + +.pos .category-button .category-name { + position: absolute; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + bottom: 0; + top: auto; + line-height: 14px; + width: 100%; + /* for some reason the -90deg orientation doesn't match the -webkit-linear-gradient. It should be 180deg here. + * webkit also insists on rendering *both* gradients instead of only the native one. So it doesn't looks right. ugh. + background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); */ + /*background:#FFF;*/ + padding: 3px; + padding-top: 15px; + color: #7C7BAD; +} + +/* e) the product */ + +.pos .product { + position:relative; + vertical-align: top; + display: inline-block; + line-height: 100px; + font-size: 11px; + margin: 8px !important; + width: 122px; + height:115px; + background:#fff; + border: 1px solid #e2e2e2; + border-radius: 3px; + border-bottom-width: 3px; + overflow: hidden; + cursor: pointer; +} + +.pos .product .product-img { + position: relative; + width: 120px; + height: 100px; + background: white; + text-align: center; +} + +.pos .product .product-img img { + max-height: 100px; + max-width: 120px; + vertical-align: middle; +} + +@media screen and (max-width: 768px) { + .pos .product-list { + padding: 0px; + } + .pos .product { + width: 32%; + height: auto; + margin: 0px !important; + } + .pos .product:active { + border: solid 50px #6ec89b; + box-sizing: border-box; + } + .pos .product:after { + content: ""; + display: block; + padding-bottom: 100%; + } + .pos .product .product-img { + position: absolute; + width: 100%; + height: 100%; + } + .pos .product .product-img img { + max-height: none; + max-width: none; + } + +} + + + +.pos .product .price-tag { + position: absolute; + top: 2px; + right: 2px; + vertical-align: top; + color: white; + line-height: 13px; + background: #7f82ac; + padding: 2px 5px; + border-radius: 2px; +} + +.pos .product .product-name { + position: absolute; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + bottom:0; + top:auto; + line-height: 14px; + width:100%; + overflow: hidden; + text-overflow: ellipsis; + background: -webkit-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + background: -moz-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + background: -ms-linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + /* troublesome in latest webkit + background: linear-gradient(-90deg,rgba(255,255,255,0),rgba(255,255,255,1), rgba(255,255,255,1)); + */ + /*background:#FFF;*/ + padding: 3px; + padding-top:15px; +} + + +/* ********* The Screens ********* */ + +.pos .screen { + position:absolute; + text-align: center; + top:0px; + bottom:0px; + width:100%; + overflow: auto; + -webkit-overflow-scrolling: touch; +} +.pos .screen header h2 { + margin-top: 0px; + padding-top: 7px; +} +.pos .screen p{ + font-size: 18px; +} +.pos .dialog{ + width: 500px; + margin-left: auto; + margin-right: auto; + margin-top: 50px; + text-align: center; +} +.pos .dialog p{ + font-size: 25px; + margin-top: 10px; + color: #5a5a5a; +} + +/* a) Generic Screen Layout Constructs */ + +.screen .screen-content{ + position: relative; + margin: 0px auto; + max-width: 1024px; + text-align: left; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.screen .screen-full-width{ + height: 100%; + width: 100%; + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +@media screen and (min-width: 1024px) { + .screen .screen-content{ + border-left: dashed 1px rgb(215,215,215); + border-right: dashed 1px rgb(215,215,215); + } +} + +.screen .top-content{ + height: 64px; + border-bottom: dashed 1px rgb(215,215,215); + text-align: center; + display: flex; + padding-right: 10px; + padding-left: 10px; +} +.screen .top-content .button { + line-height: 32px; + padding: 3px 13px; + font-size: 20px; + background: rgb(230, 230, 230); + margin-top: 12px; + margin-bottom: 12px; + margin-left: 6px; + margin-right: 6px; + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; + transition: all 150ms linear; +} +.screen .top-content .button:hover { + background: #efefef; +} +.screen .top-content .button:active { + background: black; + border-color: black; + color: white; +} +.screen .top-content .button.highlight { + background: rgb(110,200,155); + color: white; + border: solid 1px rgb(110,200,155); +} +.screen .top-content .button.highlight:hover { + background: rgb(120,210,165); +} + +.screen .top-content .top-content-center { + flex-grow: 1; +} + +.screen .main-content{ + display: flex; + flex-grow: 1; + flex-wrap: wrap; + overflow-y: auto; +} +.screen .left-content{ + overflow-x: hidden; + overflow-y: auto; + border-right: dashed 1px rgb(215,215,215); + flex-grow: 1; + min-width: 200px; +} + +.screen .right-content{ + overflow-x: hidden; + overflow-y: auto; + flex-grow: 1; + display: flex; + flex-direction: column; +} + +@media screen and (min-width: 768px) { + .screen .left-content { + max-width: 34% + } +} + +.pos .btn-switch-payment { + background-color: #6ec89b; + border-radius: 0px; + color: #FFFFFF; + font-size: 15px; + font-weight: bold; + height: 100px; + width: 100%; +} +.screen .centered-content{ + border-right: dashed 1px rgb(215,215,215); + border-left: dashed 1px rgb(215,215,215); + overflow-x: hidden; + overflow-y: auto; + max-width: 512px; + margin-left: auto; + margin-right: auto; +} +.screen .full-content{ + position: absolute; + right: 0%; top: 65px; bottom: 0px; + left: 0%; +} + +/* a) Layout for the Product Screen */ + +.pos .screen .layout-table { + border:none; + width:100%; + display: flex; + flex-grow: 1; +} + +.pos .screen .header-cell{ + border:none; + width:100%; + height:0px; +} +.pos .screen .content-row { + width:100%; + height:100%; +} +.pos .screen .content-cell{ + width:100%; +} +.pos .screen .content-cell .content-container{ + height:100%; + position:relative; +} + + +/* b) The payment screen */ + +.pos .payment-buttons-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.pos .payment-numpad { + box-sizing: border-box; + margin: 16px; + text-align: center; + flex-grow: 1; +} +.pos .payment-numpad .numpad { + border-radius: 4px; + border-top: 1px solid; + border-left: 1px solid; + border-color: #cacaca; + width: 296px; + height: 100%; + margin: auto; +} +.pos .payment-numpad .numpad button { + width: 74px; + height: 74px; +} +.pos .payment-numpad .numpad button:first-child { + border-top-left-radius: 4px; +} +.pos .payment-numpad .numpad button:nth-child(16) { + border-bottom-left-radius: 4px; +} + +.pos .paymentlines-container { + padding: 16px; + padding-top: 0; + border-bottom: dashed 1px gainsboro; + min-height: 154px; +} + +.pos .paymentlines { + border-spacing: 0px 10px; + border-collapse: inherit; + margin: 16px; +} + +.paymentlines .paymentline:first-child { + border-top-width: 1px; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.paymentlines .paymentline:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} + +.pos .paymentline { + background: #e2e2e2; + line-height: 74px; + font-size: 16px; + border: solid 1px rgb(202, 202, 202); + border-top-width: 0px; + cursor: pointer; + display: flex; + padding-left: 30px; + padding-right: 30px; + flex-grow: 1; + flex-basis: 100%; +} +.paymentline:active { + background: black; + border-color: black; + color: white; +} +.paymentline .payment-name { + flex-grow: 1; + margin-left: 10px; + margin-right: 10px; +} +.paymentline .payment-amount { + margin-left: 10px; + margin-right: 10px; +} +.paymentline .delete-button { + margin-left: 10px; + margin-right: 10px; +} +.paymentline.selected{ + background: white; +} + +.pos .payment-buttons { + box-sizing: border-box; + margin: 16px; + padding-left: 0; + flex-grow: 5; +} +.payment-screen .customer-button { + margin-bottom: 10px; +} +.payment-screen .payment-buttons .button { + background: #e2e2e2; + line-height: 73px; + font-size: 16px; + padding: 0px 8px; + border: solid 1px rgb(200,200,200); + border-top-width: 0; + cursor: pointer; + text-align: center; + position: relative; + transition: background-color, border-color, color 150ms linear; +} +.payment-screen .payment-buttons .button:hover { + background-color: #efefef; +} +.payment-screen .payment-buttons .button:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-width: 1px; +} +.payment-screen .payment-buttons .button:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} +.payment-screen .payment-buttons .button.highlight:not(:first-child) { + margin-top: -1px; + border-top: solid 1px; +} +.payment-screen .payment-buttons .button:active { + background: black; + border-color: black; + color: white; +} +.payment-screen .payment-buttons .button.highlight .fa { + border-color: rgba(0, 0, 0, 0.109804); + background: rgba(0, 0, 0, 0.0980392); +} +.payment-screen .payment-buttons .button .fa { + position: absolute; + left: 11px; + top: 50%; + width: 48px; + height: 48px; + line-height: 48px; + margin-top: -25px; + vertical-align: middle; + border-radius: 26px; + border: 1px solid rgba(0,0,0,0.2); + border-image-source: initial; + border-image-slice: initial; + border-image-width: initial; + border-image-outset: initial; + border-image-repeat: initial; + background: rgba(255,255,255,0.4); + font-size: 20px; + transition: all 150ms linear; +} +.payment-screen .paymentlines-empty .total { + text-align: center; + padding: 24px 0px 18px; + font-size: 64px; + color: #43996E; + text-shadow: 0px 2px white, 0px 2px 2px rgba(0, 0, 0, 0.27); +} +.payment-screen .paymentlines-empty .message { + text-align: center; +} + +.paymentlines .button { + cursor: pointer; + border: 1px solid #cacaca; +} +.paymentlines .electronic_payment { + background: #e2e2e2; + border-collapse: unset; + font-size: 16px; + padding-right: 0; +} + +.paymentlines .electronic_payment div:first-child { + flex-grow: 2; + margin-left: 10px; + margin-right: 10px; +} + +.paymentlines .electronic_payment div:last-child { + flex-grow: 1; + text-align: center; +} + +.payment-status-container { + display: flex; + justify-content: space-between; + font-size: 25px; + padding-top: 15px; +} + +.payment-status-total-due { + font-size: 17px; + padding-top: 12px; + padding-bottom: 12px; + color: #5c5c5cd1; +} + +.payment-status-container .amount.highlight { + font-weight: bold; + color: #6EC89B; +} + +.payment-status-container .label { + padding-right: 7px; +} + +@media screen and (max-width: 768px) { + .pos .paymentlines-container { + min-height: 0px; + border-bottom: none; + padding-bottom: 3px; + } + .pos .paymentlines { + margin-top: 0px; + margin-bottom: 0px; + } + .payment-status-container { + font-size: 22px; + } + .payment-screen .payment-buttons { + display: flex; + flex-wrap: wrap; + flex-direction: column; + } + .payment-screen .payment-controls { + display: flex; + flex-wrap: wrap; + } + .payment-screen .payment-buttons .button { + flex-basis: 30%; + flex-grow: 1; + border: 1px solid rgba(0,0,0,0.2); + border-radius: 0px; + line-height: normal; + padding-top: 28px; + padding-bottom: 28px; + } + .payment-screen .payment-buttons .button .fa { + display: none; + } +} + +/* c) The receipt screen */ + +.pos .receipt-screen .centered-content .button { + line-height: 40px; + padding: 3px 13px; + font-size: 20px; + text-align: center; + background: rgb(230, 230, 230); + margin: 16px; + margin-bottom: 0px; + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; +} + +.pos .pos-receipt-container { + font-size: 0.75em; + text-align: center; + direction: ltr; +} + +.pos .pos-receipt-container > div { + text-align: left; + width: 300px; + background-color: white; + margin: 20px; + padding: 15px; + font-size: 16px; + padding-bottom:30px; + display: inline-block; + border: solid 1px rgb(220,220,220); + border-radius: 3px; + overflow: hidden; +} + +@page { + margin: 0; +} + +@media print { + body { + background: white; + } + body * { + visibility: hidden; + } + .pos, .pos * { + position: static !important; + } + .pos .receipt-screen .pos-receipt-container { + position: absolute !important; + top: 0; + left: 0; + } + .pos .receipt-screen .pos-receipt-container, .pos .receipt-screen .pos-receipt-container * { + visibility: visible; + background: white !important; + color: black !important; + } + .pos .pos-receipt { + margin: 0 !important; + margin-left: auto !important; + margin-right: auto !important; + border: none !important; + font-size: 14px !important; + width: 266px !important; + } +} + +/* d) The Scale screen */ + +.pos .scale-screen .product-price{ + font-size: 25px; + margin: 16px; + text-align: center; + display: inline-block; + width: 35%; +} +.pos .scale-screen .computed-price{ + font-size: 25px; + display: inline-block; + text-align: right; + margin: 16px; + margin-top: 0px; + padding: 16px; + background: white; + width: 35%; + border-radius: 3px; + font-family: Inconsolata; + font-weight: bold; + text-shadow: 0px 2px 0px rgb(210,210,210); + box-shadow: 0px 2px 0px rgb(225,225,225) inset; + float: right; +} +.pos .scale-screen .buy-product{ + text-align: center; + font-size: 32px; + background: rgb(110,200,155); + color: white; + border-radius: 3px; + padding: 16px; + margin: 16px; + cursor: pointer; +} + +.pos .scale-screen .weight{ + text-align: right; + margin: 16px; + background: white; + padding: 20px; + padding-right: 30px; + font-size: 56px; + border-radius: 3px; + font-family: Inconsolata; + text-shadow: 0px 2px 0px rgb(210, 210, 210); + box-shadow: 0px 2px 0px rgb(225,225,225) inset; +} + + +/* e) The Client List Screen */ + +.pos .clientlist-screen .full-content{ + overflow: auto; +} + +.pos .clientlist-screen .client-list{ + font-size: 16px; + width: 100%; +} +.pos .clientlist-screen .client-list th, +.pos .clientlist-screen .client-list td { + padding: 12px 8px; +} +.pos .clientlist-screen .client-list tr{ + transition: all 150ms linear; + background: rgb(230,230,230); +} +.pos .clientlist-screen .client-list thead > tr, +.pos .clientlist-screen .client-list tr:nth-child(even) { + background: rgb(247,247,247); +} +.pos .clientlist-screen .client-list tr.highlight{ + transition: all 150ms linear; + background: rgb(110,200,155) !important; + color: white; +} +.pos .clientlist-screen .client-list tr.lowlight{ + transition: all 150ms linear; + background: rgb(216, 238, 227); +} +.pos .clientlist-screen .client-list tr.lowlight:nth-child(even){ + transition: all 150ms linear; + background: rgb(227, 246, 237); +} +.pos .client-line { + vertical-align: text-top; +} +.pos .edit-client-button { + margin-top: 6px; + color: black; +} +.pos .clientlist-screen .client-details{ + padding: 16px; + border-bottom: solid 5px rgb(110,200,155); +} +.pos .clientlist-screen .client-picture{ + height: 64px; + width: 64px; + border-radius: 32px; + overflow: hidden; + text-align: center; + float: left; + margin-right: 16px; + background: white; + position: relative; +} +.pos .clientlist-screen .client-picture > img { + position: absolute; + top: -9999px; + bottom: -9999px; + right: -9999px; + left: -9999px; + max-height: 64px; + margin: auto; +} +.pos .clientlist-screen .client-picture > .fa { + line-height: 64px; + font-size: 32px; +} +.pos .clientlist-screen .client-picture .image-uploader { + position: absolute; + z-index: 1000; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + cursor: pointer; +} +.pos .clientlist-screen .client-name { + font-size: 32px; + line-height: 64px; + margin-bottom:16px; +} +.pos .clientlist-screen .edit-buttons { + position: absolute; + right: 16px; + top: 10px; +} +.pos .clientlist-screen .edit-buttons .button{ + display: inline-block; + margin-left: 16px; + color: rgb(128,128,128); + cursor: pointer; + font-size: 36px; +} +.pos .clientlist-screen .client-details-box{ + position: relative; + font-size: 16px; +} +.pos .clientlist-screen .client-details-left{ + width: 50%; + float: left; +} +.pos .clientlist-screen .client-details-right{ + width: 50%; + float: right; +} +.pos .clientlist-screen .client-detail{ + line-height: 24px; +} +.pos .clientlist-screen .client-detail > .label{ + font-weight: bold; + display: inline-block; + width: 75px; + text-align: right; + margin-right: 8px; +} +.pos .clientlist-screen .client-details input, +.pos .clientlist-screen .client-details select +{ + padding: 4px; + border-radius: 3px; + border: solid 1px #cecbcb; + margin-bottom: 4px; + background: white; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + color: #555555; + width: 340px; + font-size: 14px; + box-sizing: border-box; +} +.pos .clientlist-screen .client-details input.client-name { + font-size: 24px; + line-height: 24px; + margin: 18px 6px; + width: 340px; +} +.pos .clientlist-screen .client-detail > .empty{ + opacity: 0.3; +} + +.pos .clientlist-screen .button.new-customer { + min-width: 30px; +} + +.pos .searchbox-client { + padding: 3px 13px; + margin-top: 12px; + margin-bottom : 12px; +} + +.pos .searchbox-client input { + width: 120px; + border: 1px solid #cecbcb; + padding: 10px 20px; + padding-left: 38px; + padding-right: 33px; + background-color: white; + border-radius: 20px; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + font-size: 13px; +} +.pos .searchbox-client input:focus { + outline: none; + box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset; + color: rgb(153, 153, 255); +} + +.pos .search-clear-client { + position: absolute; + top: 9px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear-client.left { + left: 11px; + color: #808080; +} + +.search-clear-client.right { + left: 145px; + color: #808080; + cursor: pointer; +} + +@media screen and (max-width: 768px) { + .searchbox-client.top-content-center { + display: flex + } + .pos .searchbox-client input { + width: auto; + flex-grow: 1; + padding-left: 10px; + padding-right: 10px; + } + .pos .clientlist-screen .client-details-box { + display: flex; + flex-wrap: wrap; + } + .pos .clientlist-screen .client-details-left{ + width: auto; + float: none; + flex-grow: 1; + } + .pos .clientlist-screen .client-details-right{ + width: auto; + float: none; + flex-grow: 1; + } + .pos .clientlist-screen .client-detail{ + display: flex; + flex-direction: column; + } + .pos .clientlist-screen .client-details input, + .pos .clientlist-screen .client-details select + { + width: 100%; + } + .pos .clientlist-screen .client-details input.client-name { + width: 100%; + } + .pos .clientlist-screen .client-detail > .label{ + width: auto; + text-align: left; + } + .pos .clientlist-screen .client-list td { + overflow: hidden; + white-space: nowrap; + } +} + + + + + +/* ********* The OrderWidget ********* */ + +.pos .order-container{ + top: 0px; + width:100%; + height: 100%; + background: white; + flex-grow: 1; + overflow: hidden; + overflow-y: auto; +} + +.pos .scrollable-y{ + overflow: hidden !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch !important; +} + +.pos .order{ + background: white; + padding-bottom: 8px; + padding-top: 8px; + font-size: 16px; + text-align: left; + max-width: 500px; + -webkit-transform: translate3d(0,0,0); +} + +.pos .order .order-empty { + text-align: center; + margin: 48px; + color: #DDD; +} +.pos .order .order-empty .fa { + font-size: 64px; +} +.pos .order .order-empty h1 { + font-size: 20px; +} + +.pos .order .summary{ + width:100%; + text-align:right; + font-weight: bold; + margin-top:20px; + margin-bottom:10px; +} +.pos .order .summary .line{ + float: right; + margin-right:15px; + margin-left: 15px; + padding-top:5px; + border-top: solid 2px; + border-color:#777; +} +.pos .order .summary .total { + font-size: 22px; +} +.pos .order .summary .line .subentry{ + font-size: 16px; + font-weight: normal; + text-align: center; +} +.pos .order .summary .line.empty{ + text-align: right; + border-color:#BBB; + color:#999; +} + +.pos .order .summary .fidpoints{ + position: absolute; + left: 20px; + padding: 10px; + color: #6EC89B; + background: rgba(110, 200, 155, 0.17); + border-radius: 3px; +} + +.submit-kitchen-button { + float: left; + background: rgb(61, 235, 82); + color: white; + padding: 12px 20px; + margin: 0px 15px; + border-radius: 3px; + cursor: pointer; +} + +/* ********* The OrderLineWidget ********* */ + +.pos .order .orderline{ + width:100%; + margin:0px; + padding-top:3px; + padding-bottom:10px; + padding-left:15px; + padding-right:15px; + cursor: pointer; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + -webkit-transition: background 250ms ease-in-out; + -moz-transition: background 250ms ease-in-out; + transition: background 250ms ease-in-out; +} +.pos .order .orderline:active{ + background: rgba(140,143,183,0.05); + -webkit-transition: background 50ms ease-in-out; + -moz-transition: background 50ms ease-in-out; + transition: background 50ms ease-in-out; +} +.pos .order .orderline.empty:active{ + background: transparent; + cursor: default; +} + +.pos .order .orderline.selected{ + background: rgba(140,143,183,0.2); + -webkit-transition: background 250ms ease-in-out; + -moz-transition: background 250ms ease-in-out; + transition: background 250ms ease-in-out; + cursor: default; +} +.pos .order .orderline .product-name{ + padding:0; + display:inline-block; + font-weight: bold; + width:80%; + overflow:hidden; + text-overflow: ellipsis; +} +.pos .order .orderline .price{ + padding:0; + font-weight: bold; + float:right; +} +.pos .order .orderline .info-list{ + color: #888; + margin-left:10px; +} +.pos .order .orderline .info-list em{ + color: #777; + font-weight: bold; + font-style:normal; +} + +/* ********* SplitBill ********* */ + +.splitbill-screen .order-info { + text-align: center; + padding: 20px 0px; + font-size: 64px; + color: #43996E; + text-shadow: 0px 2px white, 0px 2px 2px rgba(0, 0, 0, 0.27); + border-bottom: dashed 1px rgb(215,215,215); +} +.pos .splitbill-screen .order { + background: white; + padding-bottom:15px; + padding-top:15px; + margin-left:16px; + margin-right:16px; + margin-top:16px; + margin-bottom:16px; + font-size:16px; + border-radius: 3px; + border: solid 1px rgb(220,220,220); + text-align: left; + max-width: 500px; + -webkit-transform: translate3d(0,0,0); + height: max-content; + width: 100%; +} +.splitbill-screen .order .orderline.selected{ + background: rgb(110,200,155); + color: white; + -webkit-transition: background 250ms ease-in-out; + -moz-transition: background 250ms ease-in-out; + transition: background 250ms ease-in-out; + cursor: default; +} +.splitbill-screen .order .orderline.partially.selected{ + background: rgb(136, 214, 176); +} +.splitbill-screen .order .orderline.selected .info-list { + color: white; +} +.splitbill-screen .order .orderline.selected .info-list em{ + color: white; + font-size: 24px; + vertical-align: top; +} +.paymentmethods { + margin: 16px; + display: flex; + flex-wrap: wrap; +} +.paymentmethods .button { + background: #e2e2e2; + line-height: 74px; + font-size: 16px; + border: solid 1px rgb(202, 202, 202); + border-top-width: 0px; + cursor: pointer; +} +.paymentmethods .button:first-child { + border-top-width: 1px; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.paymentmethods .button:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} +.paymentmethods .button:active { + background: black; + border-color: black; + color: white; +} +.paymentmethod .button.active { + background: #6EC89B; + color: white; + border-color: #6EC89B; +} +.paymentmethod { + display: flex; + padding-left: 30px; + padding-right: 30px; + flex-grow: 1; + flex-basis: 100%; +} +.paymentmethod .payment-name { + flex-grow: 1; + margin-left: 10px; + margin-right: 10px; +} +.paymentmethod .payment-amount { + margin-left: 10px; + margin-right: 10px; +} +.paymentmethod .delete-button { + margin-left: 10px; + margin-right: 10px; +} +.paymentmethod.selected{ + background: white; +} + +@media screen and (max-width: 768px) { + .paymentmethods { + margin-top: 0px; + margin-bottom: 0px; + } + .paymentmethods .button { + border: solid 1px rgb(202, 202, 202); + border-radius: 3px; + } + .paymentmethod { + flex-basis: 30%; + } +} + +/* ********* The ActionBarWidget ********* */ + +.pos .pos-actionbar{ + height: 105px; + background: #f5f5f5; /*#ebebeb;*/ + border-top: solid 1px #cecece; + z-index:900; +} + +.pos .pos-actionbar ul{ + list-style: none; +} + +.pos .pos-actionbar-button-list{ + height: 100%; + margin: 0px; + padding-left:3px; + padding-right:3px; + overflow:hidden; +} + +.pos .pos-actionbar .button{ + width: 90px; + height: 90px; + text-align:center; + margin:3px; + margin-top:6px; + float:left; + + font-size: 14px; + font-weight: bold; + + cursor: pointer; + + border: 1px solid #cacaca; + border-radius: 3px; + + background: #e2e2e2; +} +.pos .pos-actionbar .button .label{ + margin-top: 37px; +} +.pos .pos-actionbar .button .icon{ + margin-top: 10px; +} +.pos .pos-actionbar .button:active{ + color: white; + background: #7f82ac; + border: 1px solid #7f82ac; + + -webkit-transition-property: background, border; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} + +.pos .pos-actionbar .button.disabled{ + opacity: 0.5; +} +.pos .pos-actionbar .button.disabled:active{ + border: 1px solid #cacaca; + color: #555; + cursor: default; + + background: #e2e2e2; +} + +.pos .pos-actionbar .button.rightalign{ + float:right; +} +/* ********* The Debug Widget ********* */ + +.pos .debug-widget{ + z-index:100000; + position: absolute; + right: 10px; + top: 10px; + width: 200px; + font-size: 10px; + + background: rgba(0,0,0,0.82); + color: white; + padding-bottom: 10px; + cursor: move; + -webkit-transform: translate3d(0,0,0); +} +.pos .debug-widget .toggle{ + position: absolute; + font-size: 16px; + cursor:pointer; + top:0px; + right:0px; + padding:10px; + padding-right:15px; +} +.pos .debug-widget .content{ + overflow: hidden; +} +.pos .debug-widget header { + position: relative; +} +.pos .debug-widget h1{ + background:black; + padding-top: 10px; + padding-left: 10px; + margin-top:0; + margin-bottom:0; +} +.pos .debug-widget .category{ + background: black; + padding-left: 10px; + margin: 0px; + font-weight: bold; + padding-top:3px; + padding-bottom:3px; +} +.pos .debug-widget .button{ + padding: 5px; + padding-left: 15px; + display: block; + cursor:pointer; +} +.pos .debug-widget .button:active{ + background: rgba(96,21,177,0.45); +} +.pos .debug-widget input{ + margin-left:10px; + margin-top:7px; + padding: 4px; + width: 180px; + border: none; + box-sizing: border-box; + -moz-box-sizing: border-box; + border-radius: 3px; +} +.pos .debug-widget .status{ + padding: 5px; + padding-left: 15px; + display: block; + cursor:default; +} +.pos .debug-widget .status.on{ + background-color: #6cd11d; +} +.pos .debug-widget .event{ + padding: 5px; + padding-left: 15px; + display: block; + cursor:default; + background-color: #1E1E1E; +} + +/* ********* The PopupWidgets ********* */ + +.pos .modal-dialog{ + position: absolute; + left: 0; + top: 0; + width: 100%; + height:100%; + background-color: rgba(0,0,0,0.5); + z-index:1000; +} +.pos .modal-dialog header{ + position: relative; +} +.pos .modal-dialog .popup{ + position: absolute; + margin: auto; + max-width:500px; + width: 100%; + text-align:center; + font-size:20px; + font-weight:bold; + background-color: #F0EEEE; + border-radius: 3px; + box-shadow: 0px 10px 20px rgba(0,0,0,0.4); + z-index:1200; + font-family: 'Lato'; + font-family: Lato; + /* position the popup at center and and still making it draggable*/ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.pos .modal-dialog .popup-lg{ + max-width: 80%; + max-height: 600px; + height: auto; +} + +.pos .popup .title { + background: rgba(255,255,255,0.5); + margin: 0; + padding: 20px; + border-radius: 3px 3px 0px 0px; + border-bottom: solid 1px rgba(60,60,60,0.1); +} +.pos .popup .body { + font-weight: normal; + font-size: 18px; + margin: 16px; +} + +.pos .popup-lg .body { + max-height: 400px; + overflow-y: auto; +} + +.pos .popup .body.traceback { + height: 238px; + overflow: auto; + font-size: 14px; + white-space: pre-wrap; + text-align: left; + font-family: 'Inconsolata'; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; +} +.pos .popup .footer{ + width:100%; + height:60px; + border-top: solid 1px rgba(60,60,60,0.1); +} +.pos .popup .button{ + float:right; + width: 110px; + height: 40px; + line-height:40px; + text-align:center; + border-radius: 2px; + margin-top:10px; + margin-right:10px; + + font-size: 14px; + font-weight: bold; + + cursor: pointer; + + border: solid 1px rgba(60,60,60,0.1); + + background: rgba(0,0,0,0.05); +} + +.pos .popup .button.dont-show-again { + width: 130px; +} + +.pos .popup .button.icon { + width: 40px; + font-size: 20px; +} +.pos .popup .button:active{ + color: white; + background: black; + border: 1px solid black; + + -webkit-transition-property: background, border; + -webkit-transition-duration: 0.2s; + -webkit-transition-timing-function: ease-out; +} + + +.pos .popup .button.big-left{ + position:absolute; + top: 120px; + left:40px; + width: 180px; + height: 180px; + line-height:180px; +} + +.pos .popup .button.big-right{ + position:absolute; + top: 120px; + right:40px; + width: 180px; + height: 180px; + line-height:180px; +} +.pos .popup input, +.pos .popup-input { + text-align: left; + display: inline-block; + overflow: hidden; + background: white; + min-height: 44px; + font-family: "Lato"; + font-size: 20px; + color: #444; + padding: 10px; + border-radius: 3px; + border: none; + box-shadow: 0px 0px 0px 1px rgb(220,220,220) inset; + box-sizing: border-box; + width: 80%; +} +.pos .popup .list-lines{ + overflow: auto; + height: 250px; + margin: 10px; +} +.pos .popup .list-line-input { + margin: 3px; +} + +.pos .popup-number .popup-input { + text-align: center; +} +.pos .popup input:focus, +.pos .popup-input.active { + outline: none; + box-shadow: 0px 0px 0px 3px #6EC89B; +} +.pos .popup.popup-error { + background-color: #F3BBBB; + color: rgb(168, 89, 89); + box-shadow: 0px 10px 20px rgba(92,51,51,0.4); +} +.pos .popup.popup-error .title { + color: white; + background: rgba(255, 76, 76, 0.5); +} +.pos .popup.popup-selection .selection { + overflow-y: auto; + max-height: 273px; + font-size: 16px; + width: auto; + line-height: 50px; + margin-top: -1px; + border-top: solid 3px rgba(60,60,60,0.1); + +} +.pos .popup.popup-selection .selection-item { + width: auto; + background: rgb(230,230,230); + cursor: pointer; + text-align: left; + padding: 0px 16px; +} +.pos .popup.popup-selection .selection-item:nth-child(odd) { + background: rgb(247,247,247); +} +.pos .popup.popup-selection .selection-item.selected { + background: #6EC89B; +} +.pos .popup.popup-number { + width: 300px; + height: 450px; +} +.pos .footer.centered { + text-align: center; +} +.pos .footer.centered .button { + float: none; + display: inline-block; + margin-left: 3px; + margin-right: 3px; +} +.pos .popup-numpad { + direction: ltr/*rtl:ignore*/; /* rtlcss forced to keep ltr */ + margin: 12px auto; + text-align: center; + width: 254px; +} +.pos .popup-number .title, +.pos .popup-textinput .title +{ + margin-bottom: 20px; +} +.pos .popup-numpad .input-button, +.pos .popup-numpad .mode-button { + background: none; + height: 50px; + width: 50px; + padding: 0; + border-radius: 25px; + margin: 4px; + vertical-align: top; + color: #444; +} +.pos .popup-numpad .input-button:active, +.pos .popup-numpad .mode-button:active { + background: #444; + color: white; + border-color: #444; +} + +.pos .popup.popup-password { + width: 254px; +} +.pos .popup-password .mode-button.add, +.pos .popup-password .input-button.dot { + display: none; +} +.pos .popup-password .popup-numpad { + width: 190px; +} +.pos .popup-password .popup-input { + width: 70%; + } + +.pos .popup .body ul, +.pos .popup ul.body { + text-align: left; + margin-left: 1em; +} +.pos .popup .body li { + text-indent: 1em; +} +.pos .popup .body li:before { + content: '—'; + position: relative; + font-size: 0.6em; + left: -1em; + bottom: 0.2em; +} + + +/* ********* The Webkit Scrollbar ********* */ + +.pos *::-webkit-scrollbar{ + width: 4px; + height: 4px; +} +.pos *::-webkit-scrollbar-track{ + background: rgb(224,224,224); + border-left: solid 1px rgb(200,200,200); +} +.pos *::-webkit-scrollbar-thumb{ + background: rgb(168,168,168); + background: #393939; + min-height: 30px; +} + +.pos.big-scrollbars *::-webkit-scrollbar{ + width: 40px; + height: 40px; +} +.pos.big-scrollbars *::-webkit-scrollbar-track{ + background: rgb(224,224,224); + border-left: none; +} +.pos.big-scrollbars *::-webkit-scrollbar-thumb{ + background: rgb(168,168,168); + min-height: 40px; + border-radius: 3px; +} +.pos.big-scrollbars *::-webkit-scrollbar-button{ + width: 40px; + height: 40px; + border-radius: 3px; + background: rgb(210,210,210); + background-size: cover; +} +.pos.big-scrollbars *::-webkit-scrollbar-button:decrement{ + background-image: url('../img/scroll-up.png'); +} +.pos.big-scrollbars *::-webkit-scrollbar-button:increment{ + background-image: url('../img/scroll-down.png'); +} + + +/* ********* Unsupported Browser Page ********* */ + +.pos .not-supported-browser{ + position: absolute; + z-index: 100000; + top: 0; bottom: 0; left: 0; right: 0; + background: #2C2C2C; +} +.pos .not-supported-browser .message{ + width:600px; + margin-top: 100px; + margin-left: auto; + margin-right: auto; + text-align: center; + color: #d3d3d3; + font-size: 14px; +} +.pos .not-supported-browser img{ + border-collapse: separate; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity .2s; +} + +.fade-enter, .fade-leave-to { + opacity: 0; +} + +.swing-enter-active, .swing-leave-active { + transition: opacity 0.8s; +} + +.swing-enter, .swing-leave-to { + opacity: 0; +} + +/* + We block the top-header when a temp screen is displayed. + Similar to blocking the whole ui when a popup is displayed. +*/ +.pos .block-top-header { + position: absolute; + left: 0; + top: 0; + width: 100%; + height:100%; + background-color: rgba(0,0,0,0.5); + z-index:1000; +} + +.drag-handle { + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} + +.drag-handle:active { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} + + +/* Order Management */ + +.order-row { + display: flex; + flex-direction: row; + justify-content: space-evenly; + background-color: rgb(230,230,230); +} + +.order-list .order-row:hover { + color: white; + background-color: rgb(110,200,155); + font-weight: bold; +} + +.order-row.highlight { + color: white; + background-color: rgb(110,200,155); + font-weight: bold; +} + +.order-row.lighter { + background-color: #f5f5f5; + cursor: pointer; +} + +.order-row .header { + font-size: medium; + font-weight: bolder; + flex-grow: 1; + flex-basis: 0; + text-align: left; + padding: 10px 10px; + background-color: #cecece; + border-bottom: solid 1px; + border-top: solid 1px +} + +.order-row .header.total { + text-align: right; +} + +.order-row .item { + font-size: medium; + flex-grow: 1; + flex-basis: 0; + text-align: left; + padding: 10px 10px; + border-bottom: solid rgb(150,150,150) 1px; +} + +.order-row .item.total { + text-align: right; +} + +.order-management-screen .flex-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.order-management-screen .orders { + display: flex; + flex-direction: column; +} + +.order-management-screen .order-list { + flex: 1; + overflow-y: auto; +} + +.order-management-screen .control-panel { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 0.5rem; +} + +.order-management-screen .control-panel .item { + font-size: medium; +} + +.order-management-screen .control-panel .item .page-controls { + display: flex; + flex-direction: row; + align-items: center; + font-size: x-large; +} + +.order-management-screen .control-panel .page-controls > div { + border: darkgray solid 1px; + border-radius: 2px; +} + +.order-management-screen .control-panel .page-controls > div:hover { + color: rgb(110,200,155); +} + +.order-management-screen .control-panel .page-controls .previous { + margin-right: 0.2rem; +} + +.order-management-screen .control-panel .page-controls .next { + margin-left: 0.2rem; +} + + +.order-management-screen .control-panel .search-box { + flex: 1; + position: relative; + text-align: center; + margin: 0.2rem; +} + +.order-management-screen .control-panel .search-box .clear { + position: relative; + right: 25px; + cursor: pointer; + color: #808080; + font-size: 13px; +} + +.order-management-screen .control-panel .search-box .icon { + position: relative; + left: 25px; + color: #808080; + font-size: 13px; +} + +.order-management-screen .control-panel .search-box input { + border: 1px solid #cecbcb; + padding: 10px 30px; + margin: auto; + background-color: white; + border-radius: 20px; + font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial; + font-size: 13px; +} + +.order-management-screen .control-panel .search-box input:focus { + outline: none; + box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset; + color: rgb(153, 153, 255); +} + +.order-management-screen .control-panel .button { + line-height: 32px; + padding: 3px 13px; + font-size: 20px; + background: rgb(230, 230, 230); + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; + transition: all 150ms linear; +} + +.order-management-screen .control-panel .button:hover { + background: #efefef; +} + +.order-management-screen .back-to-list { + font-size: large; + padding: 10px; + background-color: #6EC89B; + color: white; +} + +.order-receipt { + color: white; + font-size: medium; + text-align: center; +} + +.order-receipt .title { + font-size: large; +} + +/* ********* Product Configurator Popup ********* */ + +.pos .product_configurator_attributes { + text-align: left; + margin: 1em; +} + +.pos .product_configurator_attributes .attribute { + margin-bottom: 1em; + display: inline-block; + width: 45%; + padding-left: 0.5em; + vertical-align: top; +} + +@media screen and (max-width: 768px) { + .pos .product_configurator_attributes .attribute { + width: 95%; + } +} + +.pos .product_configurator_attributes .attribute_name { + margin-bottom: 0.5em; + font-weight: bold; +} + +.pos .product_configurator_attributes input { + min-height: 0; + width: auto; +} + +/** Radio attribute **/ + +.pos .product_configurator_attributes .configurator_radio { + line-height: 1.5; +} + +.pos .product_configurator_attributes .configurator_radio input[type='radio'] { + box-shadow: none; + margin-right: 0.5em; +} + +.pos .product_configurator_attributes .configurator_radio .radio_attribute_label { + font-weight: normal; + display: inline-block; + width: 80%; +} + +.pos .product_configurator_attributes .configurator_radio .price_extra { + margin-left: 0.5em; + padding: 0.2em 0.4em; + border-radius: 10rem; + color: #FFFFFF; + background-color: #6c757d; +} + +.pos .product_configurator_attributes .configurator_radio .custom_value { + margin: 0.3em 1.3em; +} + +/** Selector attribute **/ + +.pos .product_configurator_attributes .configurator_select { + cursor: pointer; + background-color: transparent; + width: 90%; + padding: 0.5em; + color: #666666; + font-size: 18px; + margin-bottom: 0.5em; +} + +/** Color attribute **/ + +.pos .product_configurator_attributes ul.color_attribute_list { + margin-left: 0; +} + +.pos .product_configurator_attributes li.color_attribute_list_item:before { + content: ''; +} + +.pos .product_configurator_attributes li.color_attribute_list_item { + margin-bottom: 0.5em; + text-indent: 0; + display: inline-block; +} + +.pos .product_configurator_attributes .color_attribute_list_item:not(:last-child) { + margin-right: 1rem; +} + +.pos .product_configurator_attributes .configurator_color { + display: inline-block; + border: 1px solid #999999; +} + +.pos .product_configurator_attributes .configurator_color.active { + border: 3px ridge #66ee66; +} + +.pos .product_configurator_attributes .configurator_color input { + margin: 20px; + opacity: 0; +} + +.pos .product_configurator_attributes .configurator_color.active input { + margin: 18px; +} + +/* TICKET SCREEN */ + +.ticket-screen { + font-size: medium; +} + +.ticket-screen .orders { + display: flex; + flex-flow: column nowrap; + overflow: hidden; + overflow-y: hidden; + overflow-y: auto; +} + +.ticket-screen .orders .header-row{ + display: flex; + flex-flow: row nowrap; + flex: 1; + justify-content: space-evenly; + background: #868686; + color: white; +} + +.ticket-screen .orders .order-row { + display: flex; + flex-flow: row nowrap; + flex: 1; + justify-content: space-evenly; +} + +.ticket-screen .orders .col { + display: flex; + flex: 1; + padding: 10px; +} + +.ticket-screen .orders .col.start { + justify-content: flex-start; +} + +.ticket-screen .orders .col.center { + justify-content: center; +} + +.ticket-screen .orders .col.end { + justify-content: flex-end; +} + +.ticket-screen .orders .col.very-narrow { + flex: 0.2; +} + +.ticket-screen .orders .col.narrow { + flex: 0.5; +} + +.ticket-screen .orders .col.wide { + flex: 1.5; +} + +.ticket-screen .order-row:nth-child(odd) { + background: #DDD; +} + +.ticket-screen .order-row:nth-child(even) { + background: white; +} + +.ticket-screen .order-row:hover { + background: rgb(110,200,155); + color: white; +} + +.ticket-screen .pointer { + cursor: pointer; +} + +.ticket-screen .controls { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0px 20px; + border-bottom: dashed 1px rgb(215,215,215); + flex: 0 0 80px; +} + +.ticket-screen .controls button { + font-size: medium; + padding: 12px; + margin-right: 20px; + font-weight: bold; +} + +.ticket-screen .controls button.discard { + background: rgb(230, 230, 230); + border: solid 1px rgb(209, 209, 209); +} + +.ticket-screen .controls button.highlight { + background: #6EC89B; + border: solid 1px #64AF8A; + color: white; +} + +.ticket-screen .pos-search-bar { + vertical-align: middle; + white-space: nowrap; + position: relative; + display: flex; + max-width: 500px; + flex: 1; +} + +.ticket-screen .pos-search-bar .search { + display: flex; + position: relative; + flex: 1; +} + +.ticket-screen .pos-search-bar .search input { + height: 40px; + font-size: medium; + color: #63717f; + padding-left: 40px; + border: solid 1px rgb(209, 209, 209); + flex: 1; +} + +.ticket-screen .pos-search-bar .radius-right { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.ticket-screen .pos-search-bar .radius-left { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.ticket-screen .pos-search-bar .search input:focus { + outline: none; +} + +.ticket-screen .pos-search-bar .search .search-icon { + position: absolute; + left: 15px; + top: 12px; + z-index: 1; + color: #4f5b66; +} + +.ticket-screen .pos-search-bar .search ul { + background: white; + position: absolute; + top: calc(100% + 5px); + right: 2px; + left: 2px; + box-shadow: 1px 1px 3px grey; + font-size: small; +} + +.ticket-screen .pos-search-bar .search li { + color: rgb(1,160,157); + margin: 0.2em 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + padding-left: 35px; +} + +.ticket-screen .pos-search-bar .search li:hover { + background: #DDD; +} + +.ticket-screen .pos-search-bar .search li .field { + font-style: italic; +} + +.ticket-screen .pos-search-bar .search li .term { + font-weight: bold; +} + +.ticket-screen .pos-search-bar .search li.highlight { + background: #DDD; +} + +.ticket-screen .pos-search-bar .filter .down-icon { + position: absolute; + right: 13px; + top: 10px; +} + +.ticket-screen .pos-search-bar .filter { + height: 40px; + background: white; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 20px; + padding-right: 40px; + border: solid 1px rgb(209, 209, 209); + border-left: none; + position: relative; + display: flex; + align-items: center; + max-width: 150px; + font-size: medium; +} + +.ticket-screen .pos-search-bar .filter:hover { + color: #868686; +} + +.ticket-screen .pos-search-bar .filter .options { + display: block; + position: absolute; + top: calc(100% + 5px); + right: 0; + z-index: 1; + box-shadow: 1px 1px 5px grey; + padding: 0.5em 0; + background: white; + color: #555555; +} + +.ticket-screen .pos-search-bar .filter ul.options li { + padding: 0.2em 1.2em; + border-top: none; + display: flex; + justify-content: start; + align-items: center; +} + +.ticket-screen .pos-search-bar .filter ul.options li:hover { + background-color: #DDD; +} + +.ticket-screen .pos-search-bar .search { + display: flex; +} + +.ticket-button { + display: flex; + align-items: center; + padding: 0 15px; + font-size: medium; + color: white; +} + +.ticket-button.highlight { + background: rgb(104,69,95); +} + +.ticket-button:hover { + background: rgb(104,69,95); + cursor: pointer; +} + +.ticket-button .with-badge { + margin-right: 0.7em; + font-size: larger; +} + +[badge] { + position: relative; +} + +[badge]:after { + background: rgb(1,160,157); + border-radius: 10rem; + color: #fff; + content: attr(badge); + font-size: small; + min-width: 20px; + padding: 2px; + position: absolute; + text-align: center; + left: 0.6em; + bottom: 0.6em; +} + +[badge^="-"]:after, +[badge="0"]:after, +[badge=""]:after { + display: none; +} + +/* Product Screen Search Bar */ + +.search-bar-portal { + display: flex; +} + +.search-bar-portal .search-box { + font-size: medium; + position: relative; + display: flex; + align-items: center; + margin: 0 15px; +} + +.search-bar-portal .search-box input { + font-size: medium; + height: 30px; + border: none; + color: #63717f; + padding-left: 38px; + padding-right: 38px; + border-radius: 5px; +} + +.search-bar-portal .search-box input:focus { + outline: none; +} + +.search-bar-portal .search-box .icon { + position: absolute; + left: 0; + margin-left: 12px; + z-index: 1; + color: #4f5b66; +} + +.search-bar-portal .search-box .clear-icon { + position: absolute; + right: 0; + margin-right: 12px; + z-index: 1; + color: #4f5b66; + cursor: pointer; +} + +.cashbox-input { + margin: auto; + line-height: 74px; + font-size: 16px; + border: solid 1px rgb(202, 202, 202); + border-top-width: 0px; + cursor: pointer; + padding-left: 30px; + padding-right: 30px; + flex-grow: 1; + flex-basis: 100%; +} + +.currencyCashBox { + font-size: 30px; +} + +.receipt-screen .default-view { + display: flex; + overflow: hidden; +} + +.receipt-screen .default-view .pos-receipt-container { + flex: 0 1 400px; + overflow: auto; +} + +.receipt-screen .default-view .actions { + flex: 1; + margin: 0 1.5rem; +} + +.receipt-screen .default-view .actions * { + font-size: 1rem; +} + +.receipt-screen .default-view .actions h1 { + font-size: 1.5rem; + margin-bottom: 4rem; +} + +.receipt-screen .default-view .actions .buttons { + display: flex; + margin: 1rem 0px; +} + +.receipt-screen .default-view .actions .buttons .button { + flex: 1; + border: solid 1px rgb(209, 209, 209); + padding: 1rem; + text-align: center; + border-radius: 3px; + cursor: pointer; + background: rgb(230, 230, 230); +} + +.receipt-screen .default-view .actions .send-email { + display: flex; +} + +.receipt-screen .default-view .actions .send-email .email-icon { + padding: 1rem; + border: solid 1px rgb(209, 209, 209); + border-right: none; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.receipt-screen .default-view .actions .send-email .input-email { + display: flex; + flex: 1; + background: white; + border: solid 1px rgb(209, 209, 209); + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.receipt-screen .default-view .actions .send-email input { + flex: 1; + padding-left: 1rem; + border: none; + color: #555555; +} + +.receipt-screen .default-view .actions .send-email input:focus { + outline: none; +} + +.receipt-screen .default-view .actions .send-email button.send { + padding: 1rem 40px; + margin: 2px; + border: none; + background: rgb(230, 230, 230); +} + +.receipt-screen .default-view .actions .send-email button.send.highlight { + color: white; + background-color: #6EC89B; +} + +.receipt-screen .notice { + margin-top: 6px; +} + +.receipt-screen .notice.failed { + color: rgb(168, 89, 89); +} + +.receipt-screen .notice.successful { + color: #6EC89B; +} + +@media screen and (max-width: 768px) { + .receipt-screen .default-view { + flex-direction: column-reverse; + overflow: auto; + } + .receipt-screen .default-view .actions { + flex: 0; + } + .receipt-screen .default-view .actions h1 { + margin-bottom: 1.5rem; + } + .receipt-screen .default-view .pos-receipt-container { + flex: 1; + overflow: visible; + } +} diff --git a/addons/point_of_sale/static/src/css/pos_receipts.css b/addons/point_of_sale/static/src/css/pos_receipts.css new file mode 100644 index 00000000..b8ed9af2 --- /dev/null +++ b/addons/point_of_sale/static/src/css/pos_receipts.css @@ -0,0 +1,65 @@ +.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-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%; + padding-left: 6em; +} + +.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-payment-terminal-receipt { + text-align: center; + font-size: 75%; +} diff --git a/addons/point_of_sale/static/src/fonts/Inconsolata.otf b/addons/point_of_sale/static/src/fonts/Inconsolata.otf Binary files differnew file mode 100644 index 00000000..34888982 --- /dev/null +++ b/addons/point_of_sale/static/src/fonts/Inconsolata.otf diff --git a/addons/point_of_sale/static/src/img/backspace.png b/addons/point_of_sale/static/src/img/backspace.png Binary files differnew file mode 100644 index 00000000..705051d9 --- /dev/null +++ b/addons/point_of_sale/static/src/img/backspace.png diff --git a/addons/point_of_sale/static/src/img/bc-arrow-big.png b/addons/point_of_sale/static/src/img/bc-arrow-big.png Binary files differnew file mode 100644 index 00000000..f845fe68 --- /dev/null +++ b/addons/point_of_sale/static/src/img/bc-arrow-big.png diff --git a/addons/point_of_sale/static/src/img/bc-arrow.png b/addons/point_of_sale/static/src/img/bc-arrow.png Binary files differnew file mode 100644 index 00000000..0485c597 --- /dev/null +++ b/addons/point_of_sale/static/src/img/bc-arrow.png diff --git a/addons/point_of_sale/static/src/img/blocks/block_simple_text.png b/addons/point_of_sale/static/src/img/blocks/block_simple_text.png Binary files differnew file mode 100644 index 00000000..7099744d --- /dev/null +++ b/addons/point_of_sale/static/src/img/blocks/block_simple_text.png diff --git a/addons/point_of_sale/static/src/img/default_category_photo.png b/addons/point_of_sale/static/src/img/default_category_photo.png Binary files differnew file mode 100644 index 00000000..25af75ee --- /dev/null +++ b/addons/point_of_sale/static/src/img/default_category_photo.png diff --git a/addons/point_of_sale/static/src/img/home.png b/addons/point_of_sale/static/src/img/home.png Binary files differnew file mode 100644 index 00000000..53d0b22d --- /dev/null +++ b/addons/point_of_sale/static/src/img/home.png diff --git a/addons/point_of_sale/static/src/img/ios-share-icon.png b/addons/point_of_sale/static/src/img/ios-share-icon.png Binary files differnew file mode 100644 index 00000000..8588657e --- /dev/null +++ b/addons/point_of_sale/static/src/img/ios-share-icon.png diff --git a/addons/point_of_sale/static/src/img/logo.png b/addons/point_of_sale/static/src/img/logo.png Binary files differnew file mode 100644 index 00000000..5bcb128d --- /dev/null +++ b/addons/point_of_sale/static/src/img/logo.png diff --git a/addons/point_of_sale/static/src/img/pos_screenshot.jpg b/addons/point_of_sale/static/src/img/pos_screenshot.jpg Binary files differnew file mode 100644 index 00000000..1e884eb8 --- /dev/null +++ b/addons/point_of_sale/static/src/img/pos_screenshot.jpg diff --git a/addons/point_of_sale/static/src/img/scroll-down.png b/addons/point_of_sale/static/src/img/scroll-down.png Binary files differnew file mode 100644 index 00000000..5fd07589 --- /dev/null +++ b/addons/point_of_sale/static/src/img/scroll-down.png diff --git a/addons/point_of_sale/static/src/img/scroll-up.png b/addons/point_of_sale/static/src/img/scroll-up.png Binary files differnew file mode 100644 index 00000000..b34a9001 --- /dev/null +++ b/addons/point_of_sale/static/src/img/scroll-up.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-128.png b/addons/point_of_sale/static/src/img/touch-icon-128.png Binary files differnew file mode 100644 index 00000000..5bbf31d6 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-128.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-196.png b/addons/point_of_sale/static/src/img/touch-icon-196.png Binary files differnew file mode 100644 index 00000000..dbde2d99 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-196.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png b/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png Binary files differnew file mode 100644 index 00000000..4f1e1db5 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-ipad-retina.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-ipad.png b/addons/point_of_sale/static/src/img/touch-icon-ipad.png Binary files differnew file mode 100644 index 00000000..8b8c1114 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-ipad.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png b/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png Binary files differnew file mode 100644 index 00000000..593c1506 --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-iphone-retina.png diff --git a/addons/point_of_sale/static/src/img/touch-icon-iphone.png b/addons/point_of_sale/static/src/img/touch-icon-iphone.png Binary files differnew file mode 100644 index 00000000..4122e61f --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon-iphone.png diff --git a/addons/point_of_sale/static/src/img/touch-icon.svg b/addons/point_of_sale/static/src/img/touch-icon.svg new file mode 100644 index 00000000..8ce5f30e --- /dev/null +++ b/addons/point_of_sale/static/src/img/touch-icon.svg @@ -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> diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js new file mode 100644 index 00000000..63f5c363 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Chrome.js @@ -0,0 +1,454 @@ +odoo.define('point_of_sale.Chrome', function(require) { + 'use strict'; + + const { useState, useRef, useContext } = owl.hooks; + const { debounce } = owl.utils; + const { loadCSS } = require('web.ajax'); + const { useListener } = require('web.custom_hooks'); + const { CrashManager } = require('web.CrashManager'); + const { BarcodeEvents } = require('barcodes.BarcodeEvents'); + const PosComponent = require('point_of_sale.PosComponent'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const PopupControllerMixin = require('point_of_sale.PopupControllerMixin'); + const Registries = require('point_of_sale.Registries'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const contexts = require('point_of_sale.PosContext'); + + // This is kind of a trick. + // We get a reference to the whole exports so that + // when we create an instance of one of the classes, + // we instantiate the extended one. + const models = require('point_of_sale.models'); + + /** + * Chrome is the root component of the PoS App. + */ + class Chrome extends PopupControllerMixin(PosComponent) { + constructor() { + super(...arguments); + useListener('show-main-screen', this.__showScreen); + useListener('toggle-debug-widget', debounce(this._toggleDebugWidget, 100)); + useListener('show-temp-screen', this.__showTempScreen); + useListener('close-temp-screen', this.__closeTempScreen); + useListener('close-pos', this._closePos); + useListener('loading-skip-callback', () => this._loadingSkipCallback()); + useListener('play-sound', this._onPlaySound); + useListener('set-sync-status', this._onSetSyncStatus); + NumberBuffer.activate(); + + this.chromeContext = useContext(contexts.chrome); + + this.state = useState({ + uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING' + debugWidgetIsShown: true, + hasBigScrollBars: false, + sound: { src: null }, + }); + + this.loading = useState({ + message: 'Loading', + skipButtonIsShown: false, + }); + + this.mainScreen = useState({ name: null, component: null }); + this.mainScreenProps = {}; + + this.tempScreen = useState({ isShown: false, name: null, component: null }); + this.tempScreenProps = {}; + + this.progressbar = useRef('progressbar'); + + this.previous_touch_y_coordinate = -1; + } + + // OVERLOADED METHODS // + + mounted() { + // remove default webclient handlers that induce click delay + $(document).off(); + $(window).off(); + $('html').off(); + $('body').off(); + // The above lines removed the bindings, but we really need them for the barcode + BarcodeEvents.start(); + } + willUnmount() { + BarcodeEvents.stop(); + } + destroy() { + super.destroy(...arguments); + this.env.pos.destroy(); + } + catchError(error) { + console.error(error); + } + + // GETTERS // + + get clientScreenButtonIsShown() { + return ( + this.env.pos.config.use_proxy && this.env.pos.config.iface_customer_facing_display + ); + } + /** + * Startup screen can be based on pos config so the startup screen + * is only determined after pos data is completely loaded. + * + * NOTE: Wait for pos data to be completed before calling this getter. + */ + get startScreen() { + if (this.state.uiState !== 'READY') { + console.warn( + `Accessing startScreen of Chrome component before 'state.uiState' to be 'READY' is not recommended.` + ); + } + return { name: 'ProductScreen' }; + } + + // CONTROL METHODS // + + /** + * Call this function after the Chrome component is mounted. + * This will load pos and assign it to the environment. + */ + async start() { + try { + // Instead of passing chrome to the instantiation the PosModel, + // we inject functions needed by pos. + // This way, we somehow decoupled Chrome from PosModel. + // We can then test PosModel independently from Chrome by supplying + // mocked version of these default attributes. + const posModelDefaultAttributes = { + env: this.env, + rpc: this.rpc.bind(this), + session: this.env.session, + do_action: this.props.webClient.do_action.bind(this.props.webClient), + setLoadingMessage: this.setLoadingMessage.bind(this), + showLoadingSkip: this.showLoadingSkip.bind(this), + setLoadingProgress: this.setLoadingProgress.bind(this), + }; + this.env.pos = new models.PosModel(posModelDefaultAttributes); + await this.env.pos.ready; + this._buildChrome(); + this._closeOtherTabs(); + this.env.pos.set( + 'selectedCategoryId', + this.env.pos.config.iface_start_categ_id + ? this.env.pos.config.iface_start_categ_id[0] + : 0 + ); + this.state.uiState = 'READY'; + this.env.pos.on('change:selectedOrder', this._showSavedScreen, this); + this._showStartScreen(); + if (_.isEmpty(this.env.pos.db.product_by_category_id)) { + this._loadDemoData(); + } + setTimeout(() => { + // 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(); + }); + } catch (error) { + let title = 'Unknown Error', + body; + + if (error.message && [100, 200, 404, -32098].includes(error.message.code)) { + // this is the signature of rpc error + if (error.message.code === -32098) { + title = 'Network Failure (XmlHttpRequestError)'; + body = + 'The Point of Sale could not be loaded due to a network problem.\n' + + 'Please check your internet connection.'; + } else if (error.message.code === 200) { + title = error.message.data.message || this.env._t('Server Error'); + body = + error.message.data.debug || + this.env._t( + 'The server encountered an error while receiving your order.' + ); + } + } else if (error instanceof Error) { + title = error.message; + body = error.stack; + } + + await this.showPopup('ErrorTracebackPopup', { + title, + body, + exitButtonIsShown: true, + }); + } + } + + // EVENT HANDLERS // + + _showStartScreen() { + const { name, props } = this.startScreen; + this.showScreen(name, props); + } + /** + * Show the screen saved in the order when the `selectedOrder` of pos is changed. + * @param {models.PosModel} pos + * @param {models.Order} newSelectedOrder + */ + _showSavedScreen(pos, newSelectedOrder) { + const { name, props } = this._getSavedScreen(newSelectedOrder); + this.showScreen(name, props); + } + _getSavedScreen(order) { + return order.get_screen_data(); + } + __showTempScreen(event) { + const { name, props, resolve } = event.detail; + this.tempScreen.isShown = true; + this.tempScreen.name = name; + this.tempScreen.component = this.constructor.components[name]; + this.tempScreenProps = Object.assign({}, props, { resolve }); + } + __closeTempScreen() { + this.tempScreen.isShown = false; + } + __showScreen({ detail: { name, props = {} } }) { + 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. Set some options + this.chromeContext.showOrderSelector = !component.hideOrderSelector; + + // 3. 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 (this.env.pos.db.get_orders().length) { + // If there are orders in the db left unsynced, we try to sync. + // If sync successful, close without asking. + // Otherwise, ask again saying that some orders are not yet synced. + try { + await this.env.pos.push_orders(); + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } catch (error) { + console.warn(error); + const reason = this.env.pos.get('failed') + ? this.env._t( + 'Some orders could not be submitted to ' + + 'the server due to configuration errors. ' + + 'You can exit the Point of Sale, but do ' + + 'not close the session before the issue ' + + 'has been resolved.' + ) + : this.env._t( + 'Some orders could not be submitted to ' + + 'the server due to internet connection issues. ' + + 'You can exit the Point of Sale, but do ' + + 'not close the session before the issue ' + + 'has been resolved.' + ); + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Offline Orders'), + body: reason, + }); + if (confirmed) { + this.state.uiState = 'CLOSING'; + this.loading.skipButtonIsShown = false; + this.setLoadingMessage(this.env._t('Closing ...')); + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } + } + } + } + _toggleDebugWidget() { + this.state.debugWidgetIsShown = !this.state.debugWidgetIsShown; + } + _onPlaySound({ detail: name }) { + let src; + if (name === 'error') { + src = "/point_of_sale/static/src/sounds/error.wav"; + } else if (name === 'bell') { + src = "/point_of_sale/static/src/sounds/bell.wav"; + } + this.state.sound.src = src; + } + _onSetSyncStatus({ detail: { status, pending }}) { + this.env.pos.set('synch', { status, pending }); + } + + // TO PASS AS PARAMETERS // + + setLoadingProgress(fac) { + if (this.progressbar.el) { + this.progressbar.el.style.width = `${Math.floor(fac * 100)}%`; + } + } + setLoadingMessage(msg, progress) { + this.loading.message = msg; + if (typeof progress !== 'undefined') { + this.setLoadingProgress(progress); + } + } + /** + * Show Skip button in the loading screen and allow to assign callback + * when the button is pressed. + * + * @param {Function} callback function to call when Skip button is pressed. + */ + showLoadingSkip(callback) { + if (callback) { + this.loading.skipButtonIsShown = true; + this._loadingSkipCallback = callback; + } + } + + get isTicketScreenShown() { + return this.mainScreen.name === 'TicketScreen'; + } + + // MISC METHODS // + + async _loadDemoData() { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('You do not have any products'), + body: this.env._t( + 'Would you like to load demo data?' + ), + }); + if (confirmed) { + await this.rpc({ + 'route': '/pos/load_onboarding_data', + }); + this.env.pos.load_server_data(); + } + } + + _preloadImages() { + for (let product of this.env.pos.db.get_product_by_category(0)) { + const image = new Image(); + image.src = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + } + for (let category of Object.values(this.env.pos.db.category_by_id)) { + if (category.id == 0) continue; + const image = new Image(); + image.src = `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`; + } + const staticImages = ['backspace.png', 'bc-arrow-big.png']; + for (let imageName of staticImages) { + const image = new Image(); + image.src = `/point_of_sale/static/src/img/${imageName}`; + } + } + + _buildChrome() { + if ($.browser.chrome) { + var chrome_version = $.browser.version.split('.')[0]; + if (parseInt(chrome_version, 10) >= 50) { + loadCSS('/point_of_sale/static/src/css/chrome50.css'); + } + } + + if (this.env.pos.config.iface_big_scrollbars) { + this.state.hasBigScrollBars = true; + } + + this._disableBackspaceBack(); + this._replaceCrashmanager(); + } + // replaces the error handling of the existing crashmanager which + // uses jquery dialog to display the error, to use the pos popup + // instead + _replaceCrashmanager() { + var self = this; + CrashManager.include({ + show_warning: function (error) { + if (self.env.pos) { + // self == this component + self.showPopup('ErrorPopup', { + title: error.data.title.toString(), + body: error.data.message, + }); + } else { + // this == CrashManager instance + this._super(error); + } + }, + show_error: function (error) { + if (self.env.pos) { + // self == this component + self.showPopup('ErrorTracebackPopup', { + title: error.type, + body: error.message + '\n' + error.data.debug + '\n', + }); + } else { + // this == CrashManager instance + this._super(error); + } + }, + }); + } + // 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 + ); + } + } + Chrome.template = 'Chrome'; + + Registries.Component.add(Chrome); + + return Chrome; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js b/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js new file mode 100644 index 00000000..02e61967 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/CashierName.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.CashierName', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously UsernameWidget + class CashierName extends PosComponent { + get username() { + const cashier = this.env.pos.get_cashier(); + if (cashier) { + return cashier.name; + } else { + return ''; + } + } + } + CashierName.template = 'CashierName'; + + Registries.Component.add(CashierName); + + return CashierName; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js new file mode 100644 index 00000000..38403b58 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js @@ -0,0 +1,87 @@ +odoo.define('point_of_sale.ClientScreenButton', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Formerly ClientScreenWidget + class ClientScreenButton extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ status: 'failure' }); + this._start(); + } + get message() { + return { + success: '', + warning: this.env._t('Connected, Not Owned'), + failure: this.env._t('Disconnected'), + not_found: this.env._t('Client Screen Unsupported. Please upgrade the IoT Box'), + }[this.state.status]; + } + async onClick() { + try { + const renderedHtml = await this.env.pos.render_html_for_customer_facing_display(); + const ownership = await this.env.pos.proxy.take_ownership_over_client_screen( + renderedHtml + ); + if (typeof ownership === 'string') { + ownership = JSON.parse(ownership); + } + if (ownership.status === 'success') { + this.state.status = 'success'; + } else { + this.state.status = 'warning'; + } + if (!this.env.pos.proxy.posbox_supports_display) { + this.env.pos.proxy.posbox_supports_display = true; + this._start(); + } + } catch (error) { + if (typeof error == 'undefined') { + this.state.status = 'failure'; + } else { + this.state.status = 'not_found'; + } + } + } + _start() { + const self = this; + async function loop() { + if (self.env.pos.proxy.posbox_supports_display) { + try { + const ownership = await self.env.pos.proxy.test_ownership_of_client_screen(); + if (typeof ownership === 'string') { + ownership = JSON.parse(ownership); + } + if (ownership.status === 'OWNER') { + self.state.status = 'success'; + } else { + self.state.status = 'warning'; + } + setTimeout(loop, 3000); + } catch (error) { + if (error.abort) { + // Stop the loop + return; + } + if (typeof error == 'undefined') { + self.state.status = 'failure'; + } else { + self.state.status = 'not_found'; + self.env.pos.proxy.posbox_supports_display = false; + } + setTimeout(loop, 3000); + } + } + } + loop(); + } + } + ClientScreenButton.template = 'ClientScreenButton'; + + Registries.Component.add(ClientScreenButton); + + return ClientScreenButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js new file mode 100644 index 00000000..f5158428 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js @@ -0,0 +1,161 @@ +odoo.define('point_of_sale.DebugWidget', function (require) { + 'use strict'; + + const { useState } = owl; + const { useRef } = owl.hooks; + const { getFileAsText } = require('point_of_sale.utils'); + const { parse } = require('web.field_utils'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class DebugWidget extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ + barcodeInput: '', + weightInput: '', + isPaidOrdersReady: false, + isUnpaidOrdersReady: false, + buffer: NumberBuffer.get(), + }); + + // NOTE: Perhaps this can still be improved. + // What we do here is loop thru the `event` elements + // then we assign animation that happens when the event is triggered + // in the proxy. E.g. if open_cashbox is sent, the open_cashbox element + // changes color from '#6CD11D' to '#1E1E1E' for a duration of 2sec. + this.eventElementsRef = {}; + this.animations = {}; + for (let eventName of ['open_cashbox', 'print_receipt', 'scale_read']) { + this.eventElementsRef[eventName] = useRef(eventName); + this.env.pos.proxy.add_notification( + eventName, + (() => { + if (this.animations[eventName]) { + this.animations[eventName].cancel(); + } + const eventElement = this.eventElementsRef[eventName].el; + eventElement.style.backgroundColor = '#6CD11D'; + this.animations[eventName] = eventElement.animate( + { backgroundColor: ['#6CD11D', '#1E1E1E'] }, + 2000 + ); + }).bind(this) + ); + } + } + mounted() { + NumberBuffer.on('buffer-update', this, this._onBufferUpdate); + } + willUnmount() { + NumberBuffer.off('buffer-update', this, this._onBufferUpdate); + } + toggleWidget() { + this.state.isShown = !this.state.isShown; + } + setWeight() { + var weightInKg = parse.float(this.state.weightInput); + if (!isNaN(weightInKg)) { + this.env.pos.proxy.debug_set_weight(weightInKg); + } + } + resetWeight() { + this.state.weightInput = ''; + this.env.pos.proxy.debug_reset_weight(); + } + barcodeScan() { + this.env.pos.barcode_reader.scan(this.state.barcodeInput); + } + barcodeScanEAN() { + const ean = this.env.pos.barcode_reader.barcode_parser.sanitize_ean( + this.state.barcodeInput || '0' + ); + this.state.barcodeInput = ean; + this.env.pos.barcode_reader.scan(ean); + } + async deleteOrders() { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Delete Paid Orders ?'), + body: this.env._t( + 'This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.' + ), + }); + if (confirmed) { + this.env.pos.db.remove_all_orders(); + this.env.pos.set_synch('connected', 0); + } + } + async deleteUnpaidOrders() { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Delete Unpaid Orders ?'), + body: this.env._t( + 'This operation will destroy all unpaid orders in the browser. You will lose all the unsaved data and exit the point of sale. This operation cannot be undone.' + ), + }); + if (confirmed) { + this.env.pos.db.remove_all_unpaid_orders(); + window.location = '/'; + } + } + _createBlob(contents) { + if (typeof contents !== 'string') { + contents = JSON.stringify(contents, null, 2); + } + return new Blob([contents]); + } + // IMPROVEMENT: Duplicated codes for downloading paid and unpaid orders. + // The implementation can be better. + preparePaidOrders() { + try { + this.paidOrdersBlob = this._createBlob(this.env.pos.export_paid_orders()); + this.state.isPaidOrdersReady = true; + } catch (error) { + console.warn(error); + } + } + get paidOrdersFilename() { + return `${this.env._t('paid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`; + } + get paidOrdersURL() { + var URL = window.URL || window.webkitURL; + return URL.createObjectURL(this.paidOrdersBlob); + } + prepareUnpaidOrders() { + try { + this.unpaidOrdersBlob = this._createBlob(this.env.pos.export_unpaid_orders()); + this.state.isUnpaidOrdersReady = true; + } catch (error) { + console.warn(error); + } + } + get unpaidOrdersFilename() { + return `${this.env._t('unpaid orders')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.json`; + } + get unpaidOrdersURL() { + var URL = window.URL || window.webkitURL; + return URL.createObjectURL(this.unpaidOrdersBlob); + } + async importOrders(event) { + const file = event.target.files[0]; + if (file) { + const report = this.env.pos.import_orders(await getFileAsText(file)); + await this.showPopup('OrderImportPopup', { report }); + } + } + refreshDisplay() { + this.env.pos.proxy.message('display_refresh', {}); + } + _onBufferUpdate(buffer) { + this.state.buffer = buffer; + } + get bufferRepr() { + return `"${this.state.buffer}"`; + } + } + DebugWidget.template = 'DebugWidget'; + + Registries.Component.add(DebugWidget); + + return DebugWidget; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js new file mode 100644 index 00000000..84036ecb --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js @@ -0,0 +1,36 @@ +odoo.define('point_of_sale.HeaderButton', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously HeaderButtonWidget + // This is the close session button + class HeaderButton extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ label: 'Close' }); + this.confirmed = null; + } + get translatedLabel() { + return this.env._t(this.state.label); + } + onClick() { + if (!this.confirmed) { + this.state.label = 'Confirm'; + this.confirmed = setTimeout(() => { + this.state.label = 'Close'; + this.confirmed = null; + }, 2000); + } else { + this.trigger('close-pos'); + } + } + } + HeaderButton.template = 'HeaderButton'; + + Registries.Component.add(HeaderButton); + + return HeaderButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js new file mode 100644 index 00000000..0bee8880 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js @@ -0,0 +1,36 @@ +odoo.define('point_of_sale.OrderManagementButton', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { isRpcError } = require('point_of_sale.utils'); + + class OrderManagementButton extends PosComponent { + async onClick() { + try { + // ping the server, if no error, show the screen + await this.rpc({ + model: 'pos.order', + method: 'browse', + args: [[]], + kwargs: { context: this.env.session.user_context }, + }); + this.showScreen('OrderManagementScreen'); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Cannot access order management screen if offline.'), + }); + } else { + throw error; + } + } + } + } + OrderManagementButton.template = 'OrderManagementButton'; + + Registries.Component.add(OrderManagementButton); + + return OrderManagementButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js b/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js new file mode 100644 index 00000000..98c24c02 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js @@ -0,0 +1,91 @@ +odoo.define('point_of_sale.ProxyStatus', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously ProxyStatusWidget + class ProxyStatus extends PosComponent { + constructor() { + super(...arguments); + const initialProxyStatus = this.env.pos.proxy.get('status'); + this.state = useState({ + status: initialProxyStatus.status, + msg: initialProxyStatus.msg, + }); + this.statuses = ['connected', 'connecting', 'disconnected', 'warning']; + this.index = 0; + } + mounted() { + this.env.pos.proxy.on('change:status', this, this._onChangeStatus); + } + willUnmount() { + this.env.pos.proxy.off('change:status', this, this._onChangeStatus); + } + async onClick() { + try { + await this.env.pos.connect_to_proxy(); + } catch (error) { + if (error instanceof Error) { + throw error; + } else { + this.showPopup('ErrorPopup', error); + } + } + } + _onChangeStatus(posProxy, statusChange) { + this._setStatus(statusChange.newValue); + } + _setStatus(newStatus) { + if (newStatus.status === 'connected') { + var warning = false; + var msg = ''; + if (this.env.pos.config.iface_scan_via_proxy) { + var scannerStatus = newStatus.drivers.scanner + ? newStatus.drivers.scanner.status + : false; + if (scannerStatus != 'connected' && scannerStatus != 'connecting') { + warning = true; + msg += this.env._t('Scanner'); + } + } + if ( + this.env.pos.config.iface_print_via_proxy || + this.env.pos.config.iface_cashdrawer + ) { + var printerStatus = newStatus.drivers.printer + ? newStatus.drivers.printer.status + : false; + if (printerStatus != 'connected' && printerStatus != 'connecting') { + warning = true; + msg = msg ? msg + ' & ' : msg; + msg += this.env._t('Printer'); + } + } + if (this.env.pos.config.iface_electronic_scale) { + var scaleStatus = newStatus.drivers.scale + ? newStatus.drivers.scale.status + : false; + if (scaleStatus != 'connected' && scaleStatus != 'connecting') { + warning = true; + msg = msg ? msg + ' & ' : msg; + msg += this.env._t('Scale'); + } + } + msg = msg ? msg + ' ' + this.env._t('Offline') : msg; + + this.state.status = warning ? 'warning' : 'connected'; + this.state.msg = msg; + } else { + this.state.status = newStatus.status; + this.state.msg = newStatus.msg || ''; + } + } + } + ProxyStatus.template = 'ProxyStatus'; + + Registries.Component.add(ProxyStatus); + + return ProxyStatus; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js new file mode 100644 index 00000000..e646547e --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js @@ -0,0 +1,38 @@ +odoo.define('point_of_sale.SaleDetailsButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class SaleDetailsButton extends PosComponent { + async onClick() { + // IMPROVEMENT: Perhaps put this logic in a parent component + // so that for unit testing, we can check if this simple + // component correctly triggers an event. + const saleDetails = await this.rpc({ + model: 'report.point_of_sale.report_saledetails', + method: 'get_sale_details', + args: [false, false, false, [this.env.pos.pos_session.id]], + }); + const report = this.env.qweb.renderToString( + 'SaleDetailsReport', + Object.assign({}, saleDetails, { + date: new Date().toLocaleString(), + pos: this.env.pos, + }) + ); + const printResult = await this.env.pos.proxy.printer.print_receipt(report); + if (!printResult.successful) { + await this.showPopup('ErrorPopup', { + title: printResult.message.title, + body: printResult.message.body, + }); + } + } + } + SaleDetailsButton.template = 'SaleDetailsButton'; + + Registries.Component.add(SaleDetailsButton); + + return SaleDetailsButton; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js b/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js new file mode 100644 index 00000000..5a4e158d --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js @@ -0,0 +1,37 @@ +odoo.define('point_of_sale.SyncNotification', function(require) { + 'use strict'; + + const { useState } = owl; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + // Previously SynchNotificationWidget + class SyncNotification extends PosComponent { + constructor() { + super(...arguments); + const synch = this.env.pos.get('synch'); + this.state = useState({ status: synch.status, msg: synch.pending }); + } + mounted() { + this.env.pos.on( + 'change:synch', + (pos, synch) => { + this.state.status = synch.status; + this.state.msg = synch.pending; + }, + this + ); + } + willUnmount() { + this.env.pos.on('change:synch', null, this); + } + onClick() { + this.env.pos.push_orders(null, { show_error: true }); + } + } + SyncNotification.template = 'SyncNotification'; + + Registries.Component.add(SyncNotification); + + return SyncNotification; +}); diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js new file mode 100644 index 00000000..d142bbde --- /dev/null +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/TicketButton.js @@ -0,0 +1,41 @@ +odoo.define('point_of_sale.TicketButton', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { posbus } = require('point_of_sale.utils'); + + class TicketButton extends PosComponent { + onClick() { + if (this.props.isTicketScreenShown) { + posbus.trigger('ticket-button-clicked'); + } else { + this.showScreen('TicketScreen'); + } + } + willPatch() { + posbus.off('order-deleted', this); + } + patched() { + posbus.on('order-deleted', this, this.render); + } + mounted() { + posbus.on('order-deleted', this, this.render); + } + willUnmount() { + posbus.off('order-deleted', this); + } + 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; +}); diff --git a/addons/point_of_sale/static/src/js/ClassRegistry.js b/addons/point_of_sale/static/src/js/ClassRegistry.js new file mode 100644 index 00000000..eed07fe3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ClassRegistry.js @@ -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; +}); diff --git a/addons/point_of_sale/static/src/js/ComponentRegistry.js b/addons/point_of_sale/static/src/js/ComponentRegistry.js new file mode 100644 index 00000000..1e820782 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ComponentRegistry.js @@ -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; +}); diff --git a/addons/point_of_sale/static/src/js/ControlButtonsMixin.js b/addons/point_of_sale/static/src/js/ControlButtonsMixin.js new file mode 100644 index 00000000..02b4c367 --- /dev/null +++ b/addons/point_of_sale/static/src/js/ControlButtonsMixin.js @@ -0,0 +1,84 @@ +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) => { + class Extended extends x { + get controlButtons() { + return this.constructor.controlButtons + .filter((cb) => { + return cb.condition.bind(this)(); + }) + .map((cb) => + Object.assign({}, cb, { component: Registries.Component.get(cb.component) }) + ); + } + } + Extended.controlButtons = []; + /** + * @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) { + this.controlButtons.push(controlButton); + } else { + // Find where to put the new controlButton. + const [locator, relativeTo] = controlButton.position; + let whereIndex = -1; + for (let i = 0; i < this.controlButtons.length; i++) { + if (this.controlButtons[i].name === relativeTo) { + if (['before', 'replace'].includes(locator)) { + whereIndex = i; + } else if (locator === 'after') { + whereIndex = i + 1; + } + break; + } + } + + // If found where to put, then perform the necessary mutation of + // the buttons array. + // Else, we just push this controlButton to the array. + if (whereIndex > -1) { + this.controlButtons.splice( + whereIndex, + locator === 'replace' ? 1 : 0, + controlButton + ); + } else { + let warningMessage = + `'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` + + 'It is pushed to the controlButtons stack instead.'; + console.warn(warningMessage); + this.controlButtons.push(controlButton); + } + } + }; + return Extended; + }; + + return ControlButtonsMixin; +}); diff --git a/addons/point_of_sale/static/src/js/Gui.js b/addons/point_of_sale/static/src/js/Gui.js new file mode 100644 index 00000000..0720b397 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Gui.js @@ -0,0 +1,60 @@ +odoo.define('point_of_sale.Gui', function (require) { + 'use strict'; + + /** + * This module bridges the data classes (such as those defined in + * models.js) to the view (owl.Component) but not vice versa. + * + * The idea is to be able to perform side-effects to the user interface + * during calculation. Think of console.log during times we want to see + * the result of calculations. This is no different, except that instead + * of printing something in the console, we access a method in the user + * interface then the user interface reacts, e.g. calling `showPopup`. + * + * This however can be dangerous to the user interface as it can be possible + * that a rendered component is destroyed during the calculation. Because of + * this, we are going to limit external ui controls to those safe ones to + * use such as: + * - `showPopup` + * - `showTempScreen` + * + * IMPROVEMENT: After all, this Gui layer seems to be a good abstraction because + * there is a complete decoupling between data and view despite the data being + * able to use selected functionalities in the view layer. More formalized + * implementation is welcome. + */ + + const config = {}; + + /** + * Call this when the user interface is ready. Provide the component + * that will be used to control the ui. + * @param {owl.component} component component having the ui methods. + */ + const configureGui = ({ component }) => { + config.component = component; + config.availableMethods = new Set([ + 'showPopup', + 'showTempScreen', + 'playSound', + 'setSyncStatus', + ]); + }; + + /** + * Import this and consume like so: `Gui.showPopup(<PopupName>, <props>)`. + * Like you would call `showPopup` in a component. + */ + const Gui = new Proxy(config, { + get(target, key) { + const { component, availableMethods } = target; + if (!component) throw new Error(`Call 'configureGui' before using Gui.`); + const isMounted = component.__owl__.status === 3 /* mounted */; + if (availableMethods.has(key) && isMounted) { + return component[key].bind(component); + } + }, + }); + + return { configureGui, Gui }; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js new file mode 100644 index 00000000..2ebdce20 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js @@ -0,0 +1,62 @@ +odoo.define('point_of_sale.AbstractReceiptScreen', function (require) { + 'use strict'; + + const { useRef } = owl.hooks; + const { nextFrame } = require('point_of_sale.utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * 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 { + constructor() { + super(...arguments); + this.orderReceipt = useRef('order-receipt'); + } + async _printReceipt() { + if (this.env.pos.proxy.printer) { + const printResult = await this.env.pos.proxy.printer.print_receipt(this.orderReceipt.el.outerHTML); + if (printResult.successful) { + return true; + } else { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: printResult.message.title, + body: '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; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/Draggable.js b/addons/point_of_sale/static/src/js/Misc/Draggable.js new file mode 100644 index 00000000..cbb1eba8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/Draggable.js @@ -0,0 +1,142 @@ +odoo.define('point_of_sale.Draggable', function(require) { + 'use strict'; + + const { useExternalListener } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * Wrap an element or a component with { position: absolute } to make it + * draggable around the limitArea or the nearest positioned ancestor. + * + * e.g. + * ``` + * <div class="limit-area"> + * <Draggable limitArea="'.limit-area'"> + * <div class="popup"> + * <header class="drag-handle"></header> + * </div> + * <div class="popup body"></div> + * </Draggable> + * </div> + * ``` + * + * In the above snippet, if the popup div is { position: absolute }, + * then it becomes draggable around the .limit-area element if it is dragged + * thru its Header -- because of the .drag-handle element. + * + * @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }` + */ + class Draggable extends PosComponent { + constructor() { + super(...arguments); + this.isDragging = false; + this.dx = 0; + this.dy = 0; + // drag with mouse + useExternalListener(document, 'mousemove', this.move); + useExternalListener(document, 'mouseup', this.endDrag); + // drag with touch + useExternalListener(document, 'touchmove', this.move); + useExternalListener(document, 'touchend', this.endDrag); + + useListener('mousedown', '.drag-handle', this.startDrag); + useListener('touchstart', '.drag-handle', this.startDrag); + } + mounted() { + this.limitArea = this.props.limitArea + ? document.querySelector(this.props.limitArea) + : this.el.offsetParent; + this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect(); + if (this.limitArea === this.el.offsetParent) { + this.limitLeft = 0; + this.limitTop = 0; + this.limitRight = this.limitAreaBoundingRect.width; + this.limitBottom = this.limitAreaBoundingRect.height; + } else { + this.limitLeft = -this.el.offsetParent.offsetLeft; + this.limitTop = -this.el.offsetParent.offsetTop; + this.limitRight = + this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft; + this.limitBottom = + this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop; + } + this.limitAreaWidth = this.limitAreaBoundingRect.width; + this.limitAreaHeight = this.limitAreaBoundingRect.height; + + // absolutely position the element then remove the transform. + const elBoundingRect = this.el.getBoundingClientRect(); + this.el.style.top = `${elBoundingRect.top}px`; + this.el.style.left = `${elBoundingRect.left}px`; + this.el.style.transform = 'none'; + } + startDrag(event) { + let realEvent; + if (event instanceof CustomEvent) { + realEvent = event.detail; + } else { + realEvent = event; + } + const { x, y } = this._getEventLoc(realEvent); + this.isDragging = true; + this.dx = this.el.offsetLeft - x; + this.dy = this.el.offsetTop - y; + event.stopPropagation(); + } + move(event) { + if (this.isDragging) { + const { x: pointerX, y: pointerY } = this._getEventLoc(event); + const posLeft = this._getPosLeft(pointerX, this.dx); + const posTop = this._getPosTop(pointerY, this.dy); + this.el.style.left = `${posLeft}px`; + this.el.style.top = `${posTop}px`; + } + } + 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; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js new file mode 100644 index 00000000..e2f2148b --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.IndependentToOrderScreen', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + + class IndependentToOrderScreen extends PosComponent { + /** + * Alias the forceTriggerSelectedOrder method as it also + * means 'closing' this screen. + */ + close() { + this.forceTriggerSelectedOrder(); + } + forceTriggerSelectedOrder() { + // Calling this method forcefully trigger change + // on the selectedOrder attribute, which then shows the screen of the + // current order, essentially closing this screen. + this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order()); + } + } + + return IndependentToOrderScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js new file mode 100644 index 00000000..024a77b3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js @@ -0,0 +1,39 @@ +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 { + constructor() { + super(...arguments); + this.pane = this.props.pane; + this.update(); + } + get order() { + return this.env.pos.get_order(); + } + mounted() { + this.order.on('change', () => { + this.update(); + this.render(); + }); + this.order.orderlines.on('change', () => { + this.update(); + this.render(); + }); + } + update() { + const total = this.order ? this.order.get_total_with_tax() : 0; + const tax = this.order ? total - this.order.get_total_without_tax() : 0; + this.total = this.env.pos.format_currency(total); + this.items_number = this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0; + } + } + + MobileOrderWidget.template = 'MobileOrderWidget'; + + Registries.Component.add(MobileOrderWidget); + + return MobileOrderWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/NotificationSound.js b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js new file mode 100644 index 00000000..540e84f1 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js @@ -0,0 +1,19 @@ +odoo.define('point_of_sale.NotificationSound', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class NotificationSound extends PosComponent { + constructor() { + super(...arguments); + useListener('ended', () => (this.props.sound.src = null)); + } + } + NotificationSound.template = 'NotificationSound'; + + Registries.Component.add(NotificationSound); + + return NotificationSound; +}); diff --git a/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js new file mode 100644 index 00000000..8e25f601 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js @@ -0,0 +1,297 @@ +odoo.define('point_of_sale.NumberBuffer', function(require) { + 'use strict'; + + const { Component } = owl; + const { EventBus } = owl.core; + const { onMounted, onWillUnmount, useExternalListener } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const { parse } = require('web.field_utils'); + const { BarcodeEvents } = require('barcodes.BarcodeEvents'); + const { _t } = require('web.core'); + const { Gui } = require('point_of_sale.Gui'); + + const INPUT_KEYS = new Set( + ['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split('')) + ); + const CONTROL_KEYS = new Set(['Enter', 'Esc']); + const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]); + const getDefaultConfig = () => ({ + decimalPoint: false, + triggerAtEnter: false, + triggerAtEsc: false, + triggerAtInput: false, + nonKeyboardInputEvent: false, + useWithBarcode: false, + }); + + /** + * This is a singleton. + * + * Only one component can `use` the buffer at a time. + * This is done by keeping track of each component (and its + * corresponding state and config) using a stack (bufferHolderStack). + * The component on top of the stack is the one that currently + * `holds` the buffer. + * + * When the current component is unmounted, the top of the stack + * is popped and NumberBuffer is set up again for the new component + * on top of the stack. + * + * Usage + * ===== + * - Activate in the construction of root component. `NumberBuffer.activate()` + * - Use the buffer in a child component by calling `NumberBuffer.use(<config>)` + * in the constructor of the child component. + * - The component that `uses` the buffer has access to the following instance + * methods of the NumberBuffer: + * - get() + * - set(val) + * - reset() + * - getFloat() + * - 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 = Component.current; + config = Object.assign(getDefaultConfig(), config); + onMounted(() => { + this.bufferHolderStack.push({ + component: currentComponent, + state: config.state ? config.state : { buffer: '' }, + config, + }); + this._setUp(); + }); + onWillUnmount(() => { + this.bufferHolderStack.pop(); + this._setUp(); + }); + // Add listener that accepts non keyboard inputs + if (typeof config.nonKeyboardInputEvent === 'string') { + useListener(config.nonKeyboardInputEvent, this._onNonKeyboardInput.bind(this)); + } + } + get _currentBufferHolder() { + return this.bufferHolderStack[this.bufferHolderStack.length - 1]; + } + _setUp() { + if (!this._currentBufferHolder) return; + const { component, state, config } = this._currentBufferHolder; + this.component = component; + this.state = state; + this.config = config; + this.decimalPoint = config.decimalPoint || this.defaultDecimalPoint; + this.maxTimeBetweenKeys = this.config.useWithBarcode + ? BarcodeEvents.max_time_between_keys_in_ms + : 0; + } + _onKeyboardInput(event) { + return this._bufferEvents(this._onInput(event => event.key))(event); + } + _onNonKeyboardInput(event) { + return this._bufferEvents(this._onInput(event => event.detail.key))(event); + } + _bufferEvents(handler) { + return event => { + if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return; + clearTimeout(this._timeout); + this.eventsBuffer.push(event); + this._timeout = setTimeout(handler, this.maxTimeBetweenKeys); + 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 (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; + } + const buffer = this.state.buffer; + if (isEmpty(buffer)) { + this.state.buffer = null; + } else { + const nCharToRemove = buffer[buffer.length - 1] === this.decimalPoint ? 2 : 1; + this.state.buffer = buffer.substring(0, buffer.length - nCharToRemove); + } + } else if (input === '+') { + if (this.state.buffer[0] === '-') { + this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length); + } + } else if (input === '-') { + if (isFirstInput) { + this.state.buffer = '-0'; + } else if (this.state.buffer[0] === '-') { + this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length); + } else { + this.state.buffer = '-' + this.state.buffer; + } + } else if (input[0] === '+' && !isNaN(parseFloat(input))) { + // when input is like '+10', '+50', etc + const inputValue = parse.float(input.slice(1)); + const currentBufferValue = this.state.buffer ? parse.float(this.state.buffer) : 0; + this.state.buffer = this.component.env.pos.formatFixed( + inputValue + currentBufferValue + ); + } else if (!isNaN(parseInt(input, 10))) { + if (isFirstInput) { + this.state.buffer = '' + input; + } else if (this.state.buffer.length > 12) { + Gui.playSound('bell'); + } else { + this.state.buffer += input; + } + } + if (this.state.buffer === '-') { + this.state.buffer = ''; + } + // once an input is accepted and updated the buffer, + // the buffer should not be in reset state anymore. + this.isReset = false; + + this.trigger('buffer-update', this.state.buffer); + } + } + + return new NumberBuffer(); +}); diff --git a/addons/point_of_sale/static/src/js/Misc/SearchBar.js b/addons/point_of_sale/static/src/js/Misc/SearchBar.js new file mode 100644 index 00000000..e9f56fea --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/SearchBar.js @@ -0,0 +1,115 @@ +odoo.define('point_of_sale.SearchBar', function (require) { + 'use strict'; + + const { useState, useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * 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: string[], + * filter: { show: boolean, options: string[] } + * }, + * placeholder: string, + * }} + * @emits search @payload { fieldValue: 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 { + constructor() { + super(...arguments); + this.config = this.props.config; + this.state = useState({ + searchInput: '', + selectedFieldId: this.config.searchFields.length ? 0 : null, + showSearchFields: false, + showFilterOptions: false, + selectedFilter: this.config.filter.options[0] || this.env._t('Select'), + }); + useExternalListener(window, 'click', this._hideOptions); + } + selectFilter(option) { + this.state.selectedFilter = option; + this.trigger('filter-selected', { filter: this.state.selectedFilter }); + } + get placeholder() { + return this.props.placeholder; + } + /** + * When vertical arrow keys are pressed, select fields for searching. + * When enter key is pressed, trigger search event if there is searchInput. + */ + onKeydown(event) { + if (['ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + this.state.selectedFieldId = this._fieldIdToSelect(event.key); + } else if (event.key === 'Enter') { + this.trigger('search', { + fieldValue: this.config.searchFields[this.state.selectedFieldId], + searchTerm: this.state.searchInput, + }); + this.state.showSearchFields = false; + } else { + if (this.state.selectedFieldId === null && this.config.searchFields.length) { + this.state.selectedFieldId = 0; + } + this.state.showSearchFields = true; + } + } + /** + * Called when a search field is clicked. + */ + onClickSearchField(id) { + this.state.showSearchFields = false; + this.trigger('search', { + fieldValue: this.config.searchFields[id], + searchTerm: this.state.searchInput, + }); + } + /** + * Given an arrow key, return the next selectedFieldId. + * E.g. If the selectedFieldId is 1 and ArrowDown is pressed, return 2. + * + * @param {string} key vertical arrow key + */ + _fieldIdToSelect(key) { + const length = this.config.searchFields.length; + if (!length) return null; + if (this.state.selectedFieldId === null) return 0; + const current = this.state.selectedFieldId || length; + return (current + (key === 'ArrowDown' ? 1 : -1)) % length; + } + _hideOptions() { + this.state.showFilterOptions = false; + this.state.showSearchFields = false; + } + } + SearchBar.template = 'point_of_sale.SearchBar'; + SearchBar.defaultProps = { + config: { + searchFields: [], + filter: { + show: false, + options: [], + }, + }, + placeholder: 'Search ...', + }; + + Registries.Component.add(SearchBar); + + return SearchBar; +}); diff --git a/addons/point_of_sale/static/src/js/PopupControllerMixin.js b/addons/point_of_sale/static/src/js/PopupControllerMixin.js new file mode 100644 index 00000000..446a514a --- /dev/null +++ b/addons/point_of_sale/static/src/js/PopupControllerMixin.js @@ -0,0 +1,44 @@ +odoo.define('point_of_sale.PopupControllerMixin', function(require) { + 'use strict'; + + const { useState } = owl; + const { useListener } = require('web.custom_hooks'); + + /** + * Allows the component declared with this mixin the ability show popup dynamically, + * provided the following: + * 1. The following element is declared in the template. It is where the Popup will be rendered. + * `<t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />` + * 2. The component should trigger `show-popup` event to show the popup and `close-popup` event + * to close. In PosComponent, `showPopup` is conveniently declared to satisfy this requirement. + * @param {Function} x class definition to mix with during extension + */ + const PopupControllerMixin = x => + class extends x { + constructor() { + super(...arguments); + useListener('show-popup', this.__showPopup); + useListener('close-popup', this.__closePopup); + + this.popup = useState({ isShown: false, name: null, component: null }); + this.popupProps = {}; // We want to avoid making the props to become Proxy! + } + __showPopup(event) { + const { name, props, resolve } = event.detail; + const popupConstructor = this.constructor.components[name]; + if (popupConstructor.dontShow) { + resolve(); + return; + } + this.popup.isShown = true; + this.popup.name = name; + this.popup.component = popupConstructor; + this.popupProps = Object.assign({}, props, { resolve }); + } + __closePopup() { + this.popup.isShown = false; + } + }; + + return PopupControllerMixin; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js new file mode 100644 index 00000000..6cdd6a04 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js @@ -0,0 +1,60 @@ +odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) { + 'use strict'; + + const { useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + + /** + * Implement this abstract class by extending it like so: + * ```js + * class ConcretePopup extends AbstractAwaitablePopup { + * async getPayload() { + * return 'result'; + * } + * } + * ConcretePopup.template = owl.tags.xml` + * <div> + * <button t-on-click="confirm">Okay</button> + * <button t-on-click="cancel">Cancel</button> + * </div> + * ` + * ``` + * + * The concrete popup can now be instantiated and be awaited for + * the user's response like so: + * ```js + * const { confirmed, payload } = await this.showPopup('ConcretePopup'); + * // based on the implementation above, + * // if confirmed, payload = 'result' + * // otherwise, payload = null + * ``` + */ + class AbstractAwaitablePopup extends PosComponent { + constructor() { + super(...arguments); + useExternalListener(window, 'keyup', this._cancelAtEscape); + } + async confirm() { + this.props.resolve({ confirmed: true, payload: await this.getPayload() }); + this.trigger('close-popup'); + } + cancel() { + this.props.resolve({ confirmed: false, payload: null }); + this.trigger('close-popup'); + } + _cancelAtEscape(event) { + if (event.key === 'Escape') { + this.cancel(); + } + } + /** + * Override this in the concrete popup implementation to set the + * payload when the popup is confirmed. + */ + async getPayload() { + return null; + } + } + + return AbstractAwaitablePopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js new file mode 100644 index 00000000..e22c1aaa --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ConfirmPopup.js @@ -0,0 +1,20 @@ +odoo.define('point_of_sale.ConfirmPopup', function(require) { + 'use strict'; + + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ConfirmPopupWidget + class ConfirmPopup extends AbstractAwaitablePopup {} + ConfirmPopup.template = 'ConfirmPopup'; + ConfirmPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Confirm ?', + body: '', + }; + + Registries.Component.add(ConfirmPopup); + + return ConfirmPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/EditListInput.js b/addons/point_of_sale/static/src/js/Popups/EditListInput.js new file mode 100644 index 00000000..09b39f21 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/EditListInput.js @@ -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; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/EditListPopup.js b/addons/point_of_sale/static/src/js/Popups/EditListPopup.js new file mode 100644 index 00000000..ac4b262d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/EditListPopup.js @@ -0,0 +1,105 @@ +odoo.define('point_of_sale.EditListPopup', function(require) { + 'use strict'; + + const { useState } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + const { useAutoFocusToLast } = require('point_of_sale.custom_hooks'); + + /** + * Given a array of { id, text }, we show the user this popup to be able to modify this given array. + * (used to replace PackLotLinePopupWidget) + * + * The expected return of showPopup when this popup is used is an array of { _id, [id], text }. + * - _id is the assigned unique identifier for each item. + * - id is the original id. if not provided, then it means that the item is new. + * - text is the modified/unmodified text. + * + * Example: + * + * ``` + * -- perhaps inside a click handler -- + * // gather the items to edit + * const names = [{ id: 1, text: 'Joseph'}, { id: 2, text: 'Kaykay' }]; + * + * // supply the items to the popup and wait for user's response + * // when user pressed `confirm` in the popup, the changes he made will be returned by the showPopup function. + * const { confirmed, payload: newNames } = await this.showPopup('EditListPopup', { + * title: "Can you confirm this item?", + * array: names }) + * + * // we then consume the new data. In this example, it is only logged. + * if (confirmed) { + * console.log(newNames); + * // the above might log the following: + * // [{ _id: 1, id: 1, text: 'Joseph Caburnay' }, { _id: 2, id: 2, 'Kaykay' }, { _id: 3, 'James' }] + * // The result showed that the original item with id=1 was changed to have text 'Joseph Caburnay' from 'Joseph' + * // The one with id=2 did not change. And a new item with text='James' is added. + * } + * ``` + */ + class EditListPopup extends AbstractAwaitablePopup { + /** + * @param {String} title required title of popup + * @param {Array} [props.array=[]] the array of { id, text } to be edited or an array of strings + * @param {Boolean} [props.isSingleItem=false] true if only allowed to edit single item (the first item) + */ + constructor() { + super(...arguments); + this._id = 0; + this.state = useState({ array: this._initialize(this.props.array) }); + useAutoFocusToLast(); + } + _nextId() { + return this._id++; + } + _emptyItem() { + return { + text: '', + _id: this._nextId(), + }; + } + _initialize(array) { + // If no array is provided, we initialize with one empty item. + if (array.length === 0) return [this._emptyItem()]; + // Put _id for each item. It will serve as unique identifier of each item. + return array.map((item) => Object.assign({}, { _id: this._nextId() }, typeof item === 'object'? item: { 'text': item})); + } + removeItem(event) { + const itemToRemove = event.detail; + this.state.array.splice( + this.state.array.findIndex(item => item._id == itemToRemove._id), + 1 + ); + // We keep a minimum of one empty item in the popup. + if (this.state.array.length === 0) { + this.state.array.push(this._emptyItem()); + } + } + createNewItem() { + if (this.props.isSingleItem) return; + this.state.array.push(this._emptyItem()); + } + /** + * @override + */ + getPayload() { + return { + newArray: this.state.array + .filter((item) => item.text.trim() !== '') + .map((item) => Object.assign({}, item)), + }; + } + } + EditListPopup.template = 'EditListPopup'; + EditListPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + array: [], + isSingleItem: false, + }; + + Registries.Component.add(EditListPopup); + + return EditListPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js new file mode 100644 index 00000000..8cf11c40 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js @@ -0,0 +1,26 @@ +odoo.define('point_of_sale.ErrorBarcodePopup', function(require) { + 'use strict'; + + const ErrorPopup = require('point_of_sale.ErrorPopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ErrorBarcodePopupWidget + class ErrorBarcodePopup extends ErrorPopup { + get translatedMessage() { + return this.env._t(this.props.message); + } + } + ErrorBarcodePopup.template = 'ErrorBarcodePopup'; + ErrorBarcodePopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Error', + body: '', + message: + 'The Point of Sale could not find any product, client, employee or action associated with the scanned barcode.', + }; + + Registries.Component.add(ErrorBarcodePopup); + + return ErrorBarcodePopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js new file mode 100644 index 00000000..865779c4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ErrorPopup.js @@ -0,0 +1,24 @@ +odoo.define('point_of_sale.ErrorPopup', function(require) { + 'use strict'; + + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ErrorPopupWidget + class ErrorPopup extends AbstractAwaitablePopup { + mounted() { + this.playSound('error'); + } + } + ErrorPopup.template = 'ErrorPopup'; + ErrorPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Error', + body: '', + }; + + Registries.Component.add(ErrorPopup); + + return ErrorPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js b/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js new file mode 100644 index 00000000..1af25e42 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js @@ -0,0 +1,44 @@ +odoo.define('point_of_sale.ErrorTracebackPopup', function(require) { + 'use strict'; + + const ErrorPopup = require('point_of_sale.ErrorPopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly ErrorTracebackPopupWidget + class ErrorTracebackPopup extends ErrorPopup { + get tracebackUrl() { + const blob = new Blob([this.props.body]); + const URL = window.URL || window.webkitURL; + return URL.createObjectURL(blob); + } + get tracebackFilename() { + return `${this.env._t('error')} ${moment().format('YYYY-MM-DD-HH-mm-ss')}.txt`; + } + emailTraceback() { + const address = this.env.pos.company.email; + const subject = this.env._t('IMPORTANT: Bug Report From Odoo Point Of Sale'); + window.open( + 'mailto:' + + address + + '?subject=' + + (subject ? window.encodeURIComponent(subject) : '') + + '&body=' + + (this.props.body ? window.encodeURIComponent(this.props.body) : '') + ); + } + } + ErrorTracebackPopup.template = 'ErrorTracebackPopup'; + ErrorTracebackPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Error with Traceback', + body: '', + exitButtonIsShown: false, + exitButtonText: 'Exit Pos', + exitButtonTrigger: 'close-pos' + }; + + Registries.Component.add(ErrorTracebackPopup); + + return ErrorTracebackPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/NumberPopup.js b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js new file mode 100644 index 00000000..bf63ba8d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/NumberPopup.js @@ -0,0 +1,79 @@ +odoo.define('point_of_sale.NumberPopup', function(require) { + 'use strict'; + var core = require('web.core'); + var _t = core._t; + + const { useState } = owl; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + // formerly NumberPopupWidget + class NumberPopup extends AbstractAwaitablePopup { + /** + * @param {Object} props + * @param {Boolean} props.isPassword Show password popup. + * @param {number|null} props.startingValue Starting value of the popup. + * + * Resolve to { confirmed, payload } when used with showPopup method. + * @confirmed {Boolean} + * @payload {String} + */ + constructor() { + super(...arguments); + useListener('accept-input', this.confirm); + useListener('close-this-popup', this.cancel); + let startingBuffer = ''; + if (typeof this.props.startingValue === 'number' && this.props.startingValue > 0) { + startingBuffer = this.props.startingValue.toString(); + } + this.state = useState({ buffer: startingBuffer }); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + triggerAtEnter: 'accept-input', + triggerAtEscape: 'close-this-popup', + state: this.state, + }); + } + get decimalSeparator() { + return this.env._t.database.parameters.decimal_point; + } + get inputBuffer() { + if (this.state.buffer === null) { + return ''; + } + if (this.props.isPassword) { + return this.state.buffer.replace(/./g, '•'); + } else { + return this.state.buffer; + } + } + confirm(event) { + const bufferState = event.detail; + if (bufferState.buffer !== '') { + 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; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js b/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js new file mode 100644 index 00000000..147ed7c4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js @@ -0,0 +1,29 @@ +odoo.define('point_of_sale.OfflineErrorPopup', function(require) { + 'use strict'; + + const ErrorPopup = require('point_of_sale.ErrorPopup'); + const Registries = require('point_of_sale.Registries'); + + /** + * This is a special kind of error popup as it introduces + * an option to not show it again. + */ + class OfflineErrorPopup extends ErrorPopup { + dontShowAgain() { + this.constructor.dontShow = true; + this.cancel(); + } + } + OfflineErrorPopup.template = 'OfflineErrorPopup'; + OfflineErrorPopup.dontShow = false; + OfflineErrorPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: 'Offline Error', + body: 'Either the server is inaccessible or browser is not connected online.', + }; + + Registries.Component.add(OfflineErrorPopup); + + return OfflineErrorPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js b/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js new file mode 100644 index 00000000..c2c35291 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/OrderImportPopup.js @@ -0,0 +1,27 @@ +odoo.define('point_of_sale.OrderImportPopup', function(require) { + 'use strict'; + + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly OrderImportPopupWidget + class OrderImportPopup extends AbstractAwaitablePopup { + get unpaidSkipped() { + return ( + (this.props.report.unpaid_skipped_existing || 0) + + (this.props.report.unpaid_skipped_session || 0) + ); + } + getPayload() {} + } + OrderImportPopup.template = 'OrderImportPopup'; + OrderImportPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + body: '', + }; + + Registries.Component.add(OrderImportPopup); + + return OrderImportPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js new file mode 100644 index 00000000..b04e55d8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js @@ -0,0 +1,89 @@ +odoo.define('point_of_sale.ProductConfiguratorPopup', function(require) { + 'use strict'; + + const { useState, useSubEnv } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + class ProductConfiguratorPopup extends AbstractAwaitablePopup { + constructor() { + super(...arguments); + 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 { + constructor() { + super(...arguments); + + 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 { + mounted() { + // 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, + }; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js new file mode 100644 index 00000000..5321fdea --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/SelectionPopup.js @@ -0,0 +1,57 @@ +odoo.define('point_of_sale.SelectionPopup', function (require) { + 'use strict'; + + const { useState } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly SelectionPopupWidget + class SelectionPopup extends AbstractAwaitablePopup { + /** + * Value of the `item` key of the selected element in the Selection + * Array is the payload of this popup. + * + * @param {Object} props + * @param {String} [props.confirmText='Confirm'] + * @param {String} [props.cancelText='Cancel'] + * @param {String} [props.title='Select'] + * @param {String} [props.body=''] + * @param {Array<Selection>} [props.list=[]] + * Selection { + * id: integer, + * label: string, + * isSelected: boolean, + * item: any, + * } + */ + constructor() { + super(...arguments); + this.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 = { + confirmText: 'Confirm', + cancelText: 'Cancel', + title: 'Select', + body: '', + list: [], + }; + + Registries.Component.add(SelectionPopup); + + return SelectionPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js b/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js new file mode 100644 index 00000000..1f2735f6 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/TextAreaPopup.js @@ -0,0 +1,39 @@ +odoo.define('point_of_sale.TextAreaPopup', function(require) { + 'use strict'; + + const { useState, useRef } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly TextAreaPopupWidget + // IMPROVEMENT: This code is very similar to TextInputPopup. + // Combining them would reduce the code. + class TextAreaPopup extends AbstractAwaitablePopup { + /** + * @param {Object} props + * @param {string} props.startingValue + */ + constructor() { + super(...arguments); + this.state = useState({ inputValue: this.props.startingValue }); + this.inputRef = useRef('input'); + } + mounted() { + this.inputRef.el.focus(); + } + getPayload() { + return this.state.inputValue; + } + } + TextAreaPopup.template = 'TextAreaPopup'; + TextAreaPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: '', + body: '', + }; + + Registries.Component.add(TextAreaPopup); + + return TextAreaPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js b/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js new file mode 100644 index 00000000..4a0612d2 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/TextInputPopup.js @@ -0,0 +1,34 @@ +odoo.define('point_of_sale.TextInputPopup', function(require) { + 'use strict'; + + const { useState, useRef } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + + // formerly TextInputPopupWidget + class TextInputPopup extends AbstractAwaitablePopup { + constructor() { + super(...arguments); + this.state = useState({ inputValue: this.props.startingValue }); + this.inputRef = useRef('input'); + } + mounted() { + this.inputRef.el.focus(); + } + getPayload() { + return this.state.inputValue; + } + } + TextInputPopup.template = 'TextInputPopup'; + TextInputPopup.defaultProps = { + confirmText: 'Ok', + cancelText: 'Cancel', + title: '', + body: '', + startingValue: '', + }; + + Registries.Component.add(TextInputPopup); + + return TextInputPopup; +}); diff --git a/addons/point_of_sale/static/src/js/PosComponent.js b/addons/point_of_sale/static/src/js/PosComponent.js new file mode 100644 index 00000000..ae2873ba --- /dev/null +++ b/addons/point_of_sale/static/src/js/PosComponent.js @@ -0,0 +1,59 @@ +odoo.define('point_of_sale.PosComponent', function (require) { + 'use strict'; + + const { Component } = owl; + + class PosComponent extends Component { + /** + * This function is available to all Components that inherit this class. + * The goal of this function is to show an awaitable dialog (popup) that + * returns a response after user interaction. See the following for quick + * demonstration: + * + * ``` + * async getUserName() { + * const userResponse = await this.showPopup( + * 'TextInputPopup', + * { title: 'What is your name?' } + * ); + * // at this point, the TextInputPopup is displayed. Depending on how the popup is defined, + * // say the input contains the name, the result of the interaction with the user is + * // saved in `userResponse`. + * console.log(userResponse); // logs { confirmed: true, payload: <name> } + * } + * ``` + * + * @param {String} name Name of the popup component + * @param {Object} props Object that will be used to render to popup + */ + showPopup(name, props) { + return new Promise((resolve) => { + this.trigger('show-popup', { name, props, resolve }); + }); + } + showTempScreen(name, props) { + return new Promise((resolve) => { + this.trigger('show-temp-screen', { name, props, resolve }); + }); + } + showScreen(name, props) { + this.trigger('show-main-screen', { name, props }); + } + /** + * @param {String} name 'bell' | 'error' + */ + playSound(name) { + this.trigger('play-sound', name); + } + /** + * Control the SyncNotification component. + * @param {String} status 'connected' | 'connecting' | 'disconnected' | 'error' + * @param {String} pending number of pending orders to sync + */ + setSyncStatus(status, pending) { + this.trigger('set-sync-status', { status, pending }); + } + } + + return PosComponent; +}); diff --git a/addons/point_of_sale/static/src/js/PosContext.js b/addons/point_of_sale/static/src/js/PosContext.js new file mode 100644 index 00000000..b52a155d --- /dev/null +++ b/addons/point_of_sale/static/src/js/PosContext.js @@ -0,0 +1,12 @@ +odoo.define('point_of_sale.PosContext', function (require) { + 'use strict'; + + const { Context } = owl; + + // Create global context objects + // e.g. component.env.device = new Context({ isMobile: false }); + return { + orderManagement: new Context({ searchString: '', selectedOrder: null }), + chrome: new Context({ showOrderSelector: true }), + }; +}); diff --git a/addons/point_of_sale/static/src/js/Registries.js b/addons/point_of_sale/static/src/js/Registries.js new file mode 100644 index 00000000..e95817ab --- /dev/null +++ b/addons/point_of_sale/static/src/js/Registries.js @@ -0,0 +1,11 @@ +odoo.define('point_of_sale.Registries', function(require) { + 'use strict'; + + /** + * This definition contains all the instances of ClassRegistry. + */ + + const ComponentRegistry = require('point_of_sale.ComponentRegistry'); + + return { Component: new ComponentRegistry() }; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js new file mode 100644 index 00000000..3c126ec2 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js @@ -0,0 +1,129 @@ +odoo.define('point_of_sale.ClientDetailsEdit', 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'); + + class ClientDetailsEdit extends PosComponent { + constructor() { + super(...arguments); + this.intFields = ['country_id', 'state_id', 'property_product_pricelist']; + const partner = this.props.partner; + this.changes = { + 'country_id': partner.country_id && partner.country_id[0], + 'state_id': partner.state_id && partner.state_id[0], + }; + } + mounted() { + this.env.bus.on('save-customer', this, this.saveChanges); + } + willUnmount() { + this.env.bus.off('save-customer', 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=image_128&write_date=${partner.write_date}&unique=1`; + } else { + return false; + } + } + /** + * Save to field `changes` all input changes from the form fields. + */ + captureChange(event) { + this.changes[event.target.name] = event.target.value; + } + saveChanges() { + let processedChanges = {}; + for (let [key, value] of Object.entries(this.changes)) { + if (this.intFields.includes(key)) { + processedChanges[key] = parseInt(value) || false; + } else { + processedChanges[key] = value; + } + } + 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(); + } + } + } + _resizeImage(img, maxwidth, maxheight) { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + var ratio = 1; + + if (img.width > maxwidth) { + ratio = maxwidth / img.width; + } + if (img.height * ratio > maxheight) { + ratio = maxheight / img.height; + } + var width = Math.floor(img.width * ratio); + var height = Math.floor(img.height * ratio); + + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + return canvas; + } + /** + * Loading image is converted to a Promise to allow await when + * loading an image. It resolves to the loaded image if succesful, + * else, resolves to false. + * + * [Source](https://stackoverflow.com/questions/45788934/how-to-turn-this-callback-into-a-promise-using-async-await) + */ + _loadImage(url) { + return new Promise((resolve) => { + const img = new Image(); + img.addEventListener('load', () => resolve(img)); + img.addEventListener('error', () => { + this.showPopup('ErrorPopup', { + title: this.env._t('Loading Image Error'), + body: this.env._t( + 'Encountered error when loading image. Please try again.' + ), + }); + resolve(false); + }); + img.src = url; + }); + } + } + ClientDetailsEdit.template = 'ClientDetailsEdit'; + + Registries.Component.add(ClientDetailsEdit); + + return ClientDetailsEdit; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js new file mode 100644 index 00000000..86f55645 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js @@ -0,0 +1,17 @@ +odoo.define('point_of_sale.ClientLine', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ClientLine extends PosComponent { + get highlight() { + return this.props.partner !== this.props.selectedClient ? '' : 'highlight'; + } + } + ClientLine.template = 'ClientLine'; + + Registries.Component.add(ClientLine); + + return ClientLine; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js new file mode 100644 index 00000000..4863d588 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js @@ -0,0 +1,182 @@ +odoo.define('point_of_sale.ClientListScreen', function(require) { + 'use strict'; + + const { debounce } = owl.utils; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + + /** + * Render this screen using `showTempScreen` to select client. + * When the shown screen is confirmed ('Set Customer' or 'Deselect Customer' + * button is clicked), the call to `showTempScreen` resolves to the + * selected client. E.g. + * + * ```js + * const { confirmed, payload: selectedClient } = await showTempScreen('ClientListScreen'); + * if (confirmed) { + * // do something with the selectedClient + * } + * ``` + * + * @props client - originally selected client + */ + class ClientListScreen extends PosComponent { + constructor() { + super(...arguments); + useListener('click-save', () => this.env.bus.trigger('save-customer')); + useListener('click-edit', () => this.editClient()); + useListener('save-changes', this.saveChanges); + + // We are not using useState here because the object + // passed to useState converts the object and its contents + // to Observer proxy. Not sure of the side-effects of making + // a persistent object, such as pos, into owl.Observer. But it + // is better to be safe. + this.state = { + query: null, + selectedClient: this.props.client, + detailIsShown: false, + isEditMode: false, + editModeProps: { + partner: { + country_id: this.env.pos.company.country_id, + state_id: this.env.pos.company.state_id, + } + }, + }; + this.updateClientList = debounce(this.updateClientList, 70); + } + + // Lifecycle hooks + back() { + if(this.state.detailIsShown) { + this.state.detailIsShown = false; + this.render(); + } else { + this.props.resolve({ confirmed: false, payload: false }); + this.trigger('close-temp-screen'); + } + } + confirm() { + this.props.resolve({ confirmed: true, payload: this.state.selectedClient }); + this.trigger('close-temp-screen'); + } + // Getters + + get currentOrder() { + return this.env.pos.get_order(); + } + + get clients() { + if (this.state.query && this.state.query.trim() !== '') { + return this.env.pos.db.search_partner(this.state.query.trim()); + } else { + return this.env.pos.db.get_partners_sorted(1000); + } + } + get isNextButtonVisible() { + return this.state.selectedClient ? true : false; + } + /** + * Returns the text and command of the next button. + * The command field is used by the clickNext call. + */ + get nextButton() { + if (!this.props.client) { + return { command: 'set', text: this.env._t('Set Customer') }; + } else if (this.props.client && this.props.client === this.state.selectedClient) { + return { command: 'deselect', text: this.env._t('Deselect Customer') }; + } else { + return { command: 'set', text: this.env._t('Change Customer') }; + } + } + + // Methods + + // We declare this event handler as a debounce function in + // order to lower its trigger rate. + updateClientList(event) { + this.state.query = event.target.value; + const clients = this.clients; + if (event.code === 'Enter' && clients.length === 1) { + this.state.selectedClient = clients[0]; + this.clickNext(); + } else { + this.render(); + } + } + clickClient(event) { + let partner = event.detail.client; + if (this.state.selectedClient === partner) { + this.state.selectedClient = null; + } else { + this.state.selectedClient = partner; + } + this.render(); + } + editClient() { + this.state.editModeProps = { + partner: this.state.selectedClient, + }; + this.state.detailIsShown = true; + this.render(); + } + clickNext() { + this.state.selectedClient = this.nextButton.command === 'set' ? this.state.selectedClient : null; + this.confirm(); + } + activateEditMode(event) { + const { isNewClient } = event.detail; + this.state.isEditMode = true; + this.state.detailIsShown = true; + this.state.isNewClient = isNewClient; + if (!isNewClient) { + this.state.editModeProps = { + partner: this.state.selectedClient, + }; + } + this.render(); + } + deactivateEditMode() { + this.state.isEditMode = false; + this.state.editModeProps = { + partner: { + country_id: this.env.pos.company.country_id, + state_id: this.env.pos.company.state_id, + }, + }; + this.render(); + } + async saveChanges(event) { + try { + let partnerId = await this.rpc({ + model: 'res.partner', + method: 'create_from_ui', + args: [event.detail.processedChanges], + }); + await this.env.pos.load_new_partners(); + this.state.selectedClient = this.env.pos.db.get_partner_by_id(partnerId); + this.state.detailIsShown = false; + this.render(); + } catch (error) { + if (error.message.code < 0) { + await this.showPopup('OfflineErrorPopup', { + title: this.env._t('Offline'), + body: this.env._t('Unable to save changes.'), + }); + } else { + throw error; + } + } + } + cancelEdit() { + this.deactivateEditMode(); + } + } + ClientListScreen.template = 'ClientListScreen'; + + Registries.Component.add(ClientListScreen); + + return ClientListScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js new file mode 100644 index 00000000..53b858ba --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js @@ -0,0 +1,155 @@ +odoo.define('point_of_sale.InvoiceButton', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const { useContext } = owl.hooks; + const { isRpcError } = require('point_of_sale.utils'); + const PosComponent = require('point_of_sale.PosComponent'); + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const OrderFetcher = require('point_of_sale.OrderFetcher'); + const Registries = require('point_of_sale.Registries'); + const contexts = require('point_of_sale.PosContext'); + + class InvoiceButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this._onClick); + this.orderManagementContext = useContext(contexts.orderManagement); + } + get selectedOrder() { + return this.orderManagementContext.selectedOrder; + } + set selectedOrder(value) { + this.orderManagementContext.selectedOrder = value; + } + get isAlreadyInvoiced() { + if (!this.selectedOrder) return false; + return Boolean(this.selectedOrder.account_move); + } + get commandName() { + if (!this.selectedOrder) { + return 'Invoice'; + } else { + return this.isAlreadyInvoiced + ? 'Reprint Invoice' + : this.selectedOrder.isFromClosedSession + ? 'Cannot Invoice' + : 'Invoice'; + } + } + get isHighlighted() { + return this.selectedOrder && !this.isAlreadyInvoiced && !this.selectedOrder.isFromClosedSession; + } + async _downloadInvoice(orderId) { + try { + await this.env.pos.do_action('point_of_sale.pos_invoice_report', { + additional_context: { + active_ids: [orderId], + }, + }); + } catch (error) { + if (error instanceof Error) { + throw error; + } else { + // NOTE: error here is most probably undefined + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Unable to download invoice.'), + }); + } + } + } + async _invoiceOrder() { + const order = this.selectedOrder; + if (!order) return; + + const orderId = order.backendId; + + // Part 0.1. If already invoiced, print the invoice. + if (this.isAlreadyInvoiced) { + await this._downloadInvoice(orderId); + return; + } + + // Part 0.2. Check if order belongs to an active session. + // If not, do not allow invoicing. + if (order.isFromClosedSession) { + this.showPopup('ErrorPopup', { + title: this.env._t('Session is closed'), + body: this.env._t('Cannot invoice order from closed session.'), + }); + return; + } + + // Part 1: Handle missing client. + // Write to pos.order the selected client. + if (!order.get_client()) { + const { confirmed: confirmedPopup } = await this.showPopup('ConfirmPopup', { + title: 'Need customer to invoice', + body: 'Do you want to open the customer list to select customer?', + }); + if (!confirmedPopup) return; + + const { confirmed: confirmedTempScreen, payload: newClient } = await this.showTempScreen( + 'ClientListScreen' + ); + if (!confirmedTempScreen) return; + + await this.rpc({ + model: 'pos.order', + method: 'write', + args: [[orderId], { partner_id: newClient.id }], + kwargs: { context: this.env.session.user_context }, + }); + } + + // Part 2: Invoice the order. + await this.rpc( + { + model: 'pos.order', + method: 'action_pos_order_invoice', + args: [orderId], + kwargs: { context: this.env.session.user_context }, + }, + { + timeout: 30000, + shadow: true, + } + ); + + // Part 3: Download invoice. + await this._downloadInvoice(orderId); + + // Invalidate the cache then fetch the updated order. + OrderFetcher.invalidateCache([orderId]); + await OrderFetcher.fetch(); + this.selectedOrder = OrderFetcher.get(this.selectedOrder.backendId); + } + async _onClick() { + try { + await this._invoiceOrder(); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Unable to invoice order.'), + }); + } else { + throw error; + } + } + } + } + InvoiceButton.template = 'InvoiceButton'; + + OrderManagementScreen.addControlButton({ + component: InvoiceButton, + condition: function () { + return this.env.pos.config.module_account; + }, + }); + + Registries.Component.add(InvoiceButton); + + return InvoiceButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js new file mode 100644 index 00000000..5a227827 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js @@ -0,0 +1,36 @@ +odoo.define('point_of_sale.ReprintReceiptButton', function (require) { + 'use strict'; + + const { useListener } = require('web.custom_hooks'); + const { useContext } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const Registries = require('point_of_sale.Registries'); + const contexts = require('point_of_sale.PosContext'); + + class ReprintReceiptButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this._onClick); + this.orderManagementContext = useContext(contexts.orderManagement); + } + async _onClick() { + const order = this.orderManagementContext.selectedOrder; + if (!order) return; + + this.showScreen('ReprintReceiptScreen', { order: order }); + } + } + ReprintReceiptButton.template = 'ReprintReceiptButton'; + + OrderManagementScreen.addControlButton({ + component: ReprintReceiptButton, + condition: function () { + return true; + }, + }); + + Registries.Component.add(ReprintReceiptButton); + + return ReprintReceiptButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js new file mode 100644 index 00000000..b5766ccf --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js @@ -0,0 +1,25 @@ +odoo.define('point_of_sale.MobileOrderManagementScreen', function (require) { + const OrderManagementScreen = require('point_of_sale.OrderManagementScreen'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + const { useState } = owl.hooks; + + const MobileOrderManagementScreen = (OrderManagementScreen) => { + class MobileOrderManagementScreen extends OrderManagementScreen { + constructor() { + super(...arguments); + useListener('click-order', this._onShowDetails) + this.mobileState = useState({ showDetails: false }); + } + _onShowDetails() { + this.mobileState.showDetails = true; + } + } + MobileOrderManagementScreen.template = 'MobileOrderManagementScreen'; + return MobileOrderManagementScreen; + }; + + Registries.Component.addByExtending(MobileOrderManagementScreen, OrderManagementScreen); + + return MobileOrderManagementScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js new file mode 100644 index 00000000..cc0c671c --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js @@ -0,0 +1,29 @@ +odoo.define('point_of_sale.OrderDetails', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} order + */ + class OrderDetails extends PosComponent { + get order() { + return this.props.order; + } + get orderlines() { + return this.order ? this.order.orderlines.models : []; + } + get total() { + return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0); + } + get tax() { + return this.env.pos.format_currency(this.order ? this.order.get_total_tax() : 0) + } + } + OrderDetails.template = 'OrderDetails'; + + Registries.Component.add(OrderDetails); + + return OrderDetails; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js new file mode 100644 index 00000000..57a02635 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js @@ -0,0 +1,214 @@ +odoo.define('point_of_sale.OrderFetcher', function (require) { + 'use strict'; + + const { EventBus } = owl.core; + const { Gui } = require('point_of_sale.Gui'); + const { isRpcError } = require('point_of_sale.utils'); + const models = require('point_of_sale.models'); + + class OrderFetcher extends EventBus { + constructor() { + super(); + this.currentPage = 1; + this.ordersToShow = []; + this.cache = {}; + this.totalCount = 0; + } + get activeOrders() { + const allActiveOrders = this.comp.env.pos.get('orders').models; + return this.searchDomain + ? allActiveOrders.filter(this._predicateBasedOnSearchDomain.bind(this)) + : allActiveOrders; + } + _predicateBasedOnSearchDomain(order) { + function check(order, field, searchWord) { + searchWord = searchWord.toLowerCase(); + switch (field) { + case 'pos_reference': + return order.name.toLowerCase().includes(searchWord); + case 'partner_id.display_name': + const client = order.get_client(); + return client ? client.name.toLowerCase().includes(searchWord) : false; + case 'date_order': + return moment(order.creation_date).format('YYYY-MM-DD hh:mm A').includes(searchWord); + default: + return false; + } + } + for (let [field, _, searchWord] of (this.searchDomain || []).filter((item) => item !== '|')) { + // remove surrounding "%" from `searchWord` + searchWord = searchWord.substring(1, searchWord.length - 1); + if (check(order, field, searchWord)) { + return true; + } + } + return false; + } + get nActiveOrders() { + return this.activeOrders.length; + } + get lastPageFullOfActiveOrders() { + return Math.trunc(this.nActiveOrders / this.nPerPage); + } + get remainingActiveOrders() { + return this.nActiveOrders % this.nPerPage; + } + /** + * for nPerPage = 10 + * +--------+----------+ + * | nItems | lastPage | + * +--------+----------+ + * | 2 | 1 | + * | 10 | 1 | + * | 11 | 2 | + * | 30 | 3 | + * | 35 | 4 | + * +--------+----------+ + */ + get lastPage() { + const nItems = this.nActiveOrders + this.totalCount; + return Math.trunc(nItems / (this.nPerPage + 1)) + 1; + } + /** + * Calling this methods populates the `ordersToShow` then trigger `update` event. + * @related get + * + * NOTE: This is tightly-coupled with pagination. So if the current page contains all + * active orders, it will not fetch anything from the server but only sets `ordersToShow` + * to the active orders that fits the current page. + */ + async fetch() { + try { + let limit, offset; + let start, end; + if (this.currentPage <= this.lastPageFullOfActiveOrders) { + // Show only active orders. + start = (this.currentPage - 1) * this.nPerPage; + end = this.currentPage * this.nPerPage; + this.ordersToShow = this.activeOrders.slice(start, end); + } else if (this.currentPage === this.lastPageFullOfActiveOrders + 1) { + // Show partially the remaining active orders and + // some orders from the backend. + offset = 0; + limit = this.nPerPage - this.remainingActiveOrders; + start = (this.currentPage - 1) * this.nPerPage; + end = this.nActiveOrders; + this.ordersToShow = [ + ...this.activeOrders.slice(start, end), + ...(await this._fetch(limit, offset)), + ]; + } else { + // Show orders from the backend. + offset = + this.nPerPage - + this.remainingActiveOrders + + (this.currentPage - (this.lastPageFullOfActiveOrders + 1) - 1) * + this.nPerPage; + limit = this.nPerPage; + this.ordersToShow = await this._fetch(limit, offset); + } + this.trigger('update'); + } catch (error) { + if (isRpcError(error) && error.message.code < 0) { + Gui.showPopup('ErrorPopup', { + title: this.comp.env._t('Network Error'), + body: this.comp.env._t('Unable to fetch orders if offline.'), + }); + Gui.setSyncStatus('error'); + } else { + throw error; + } + } + } + /** + * This returns the orders from the backend that needs to be shown. + * If the order is already in cache, the full information about that + * order is not fetched anymore, instead, we use info from cache. + * + * @param {number} limit + * @param {number} offset + */ + async _fetch(limit, offset) { + const { ids, totalCount } = await this._getOrderIdsForCurrentPage(limit, offset); + const idsNotInCache = ids.filter((id) => !(id in this.cache)); + if (idsNotInCache.length > 0) { + const fetchedOrders = await this._fetchOrders(idsNotInCache); + // Cache these fetched orders so that next time, no need to fetch + // them again, unless invalidated. See `invalidateCache`. + fetchedOrders.forEach((order) => { + this.cache[order.id] = new models.Order( + {}, + { pos: this.comp.env.pos, json: order } + ); + }); + } + this.totalCount = totalCount; + return ids.map((id) => this.cache[id]); + } + async _getOrderIdsForCurrentPage(limit, offset) { + return await this.rpc({ + model: 'pos.order', + method: 'search_paid_order_ids', + kwargs: { config_id: this.configId, domain: this.searchDomain ? this.searchDomain : [], limit, offset }, + context: this.comp.env.session.user_context, + }); + } + async _fetchOrders(ids) { + return await this.rpc({ + model: 'pos.order', + method: 'export_for_ui', + args: [ids], + context: this.comp.env.session.user_context, + }); + } + nextPage() { + if (this.currentPage < this.lastPage) { + this.currentPage += 1; + this.fetch(); + } + } + prevPage() { + if (this.currentPage > 1) { + this.currentPage -= 1; + this.fetch(); + } + } + /** + * @param {integer|undefined} id id of the cached order + * @returns {Array<models.Order>} + */ + get(id) { + if (id) return this.cache[id]; + return this.ordersToShow; + } + setSearchDomain(searchDomain) { + this.searchDomain = searchDomain; + } + setComponent(comp) { + this.comp = comp; + return this; + } + setConfigId(configId) { + this.configId = configId; + } + setNPerPage(val) { + this.nPerPage = val; + } + setPage(page) { + this.currentPage = page; + } + invalidateCache(ids) { + for (let id of ids) { + delete this.cache[id]; + } + } + async rpc() { + Gui.setSyncStatus('connecting'); + const result = await this.comp.rpc(...arguments); + Gui.setSyncStatus('connected'); + return result; + } + } + + return new OrderFetcher(); +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js new file mode 100644 index 00000000..2b4d3cd9 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js @@ -0,0 +1,31 @@ +odoo.define('point_of_sale.OrderList', function (require) { + 'use strict'; + + const { useState } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} [initHighlightedOrder] initially highligted order + * @props {Array<models.Order>} orders + */ + class OrderList extends PosComponent { + constructor() { + super(...arguments); + useListener('click-order', this._onClickOrder); + this.state = useState({ highlightedOrder: this.props.initHighlightedOrder || null }); + } + get highlightedOrder() { + return this.state.highlightedOrder; + } + _onClickOrder({ detail: order }) { + this.state.highlightedOrder = order; + } + } + OrderList.template = 'OrderList'; + + Registries.Component.add(OrderList); + + return OrderList; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js new file mode 100644 index 00000000..951a0956 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js @@ -0,0 +1,124 @@ +odoo.define('point_of_sale.OrderManagementControlPanel', function (require) { + 'use strict'; + + const { useContext } = owl.hooks; + const { useAutofocus, useListener } = require('web.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const OrderFetcher = require('point_of_sale.OrderFetcher'); + const contexts = require('point_of_sale.PosContext'); + + // NOTE: These are constants so that they are only instantiated once + // and they can be used efficiently by the OrderManagementControlPanel. + const VALID_SEARCH_TAGS = new Set(['date', 'customer', 'client', 'name', 'order']); + const FIELD_MAP = { + date: 'date_order', + customer: 'partner_id.display_name', + client: 'partner_id.display_name', + name: 'pos_reference', + order: 'pos_reference', + }; + const SEARCH_FIELDS = ['pos_reference', 'partner_id.display_name', 'date_order']; + + function getDomainForSingleCondition(fields, toSearch) { + const orSymbols = Array(fields.length - 1).fill('|'); + return orSymbols.concat(fields.map((field) => [field, 'ilike', `%${toSearch}%`])); + } + + /** + * @emits close-screen + * @emits prev-page + * @emits next-page + * @emits search + */ + class OrderManagementControlPanel extends PosComponent { + constructor() { + super(...arguments); + // We are using context because we want the `searchString` to be alive + // even if this component is destroyed (unmounted). + this.orderManagementContext = useContext(contexts.orderManagement); + useListener('clear-search', this._onClearSearch); + useAutofocus({ selector: 'input' }); + } + onInputKeydown(event) { + if (event.key === 'Enter') { + this.trigger('search', this._computeDomain()); + } + } + get showPageControls() { + return OrderFetcher.lastPage > 1; + } + get pageNumber() { + const currentPage = OrderFetcher.currentPage; + const lastPage = OrderFetcher.lastPage; + return isNaN(lastPage) ? '' : `(${currentPage}/${lastPage})`; + } + get validSearchTags() { + return VALID_SEARCH_TAGS; + } + get fieldMap() { + return FIELD_MAP; + } + get searchFields() { + return SEARCH_FIELDS; + } + /** + * E.g. 1 + * ``` + * searchString = 'Customer 1' + * result = [ + * '|', + * '|', + * ['pos_reference', 'ilike', '%Customer 1%'], + * ['partner_id.display_name', 'ilike', '%Customer 1%'], + * ['date_order', 'ilike', '%Customer 1%'] + * ] + * ``` + * + * E.g. 2 + * ``` + * searchString = 'date: 2020-05' + * result = [ + * ['date_order', 'ilike', '%2020-05%'] + * ] + * ``` + * + * E.g. 3 + * ``` + * searchString = 'customer: Steward, date: 2020-05-01' + * result = [ + * ['partner_id.display_name', 'ilike', '%Steward%'], + * ['date_order', 'ilike', '%2020-05-01%'] + * ] + * ``` + */ + _computeDomain() { + const input = this.orderManagementContext.searchString.trim(); + if (!input) return; + + const searchConditions = this.orderManagementContext.searchString.split(/[,&]\s*/); + if (searchConditions.length === 1) { + let cond = searchConditions[0].split(/:\s*/); + if (cond.length === 1) { + return getDomainForSingleCondition(this.searchFields, cond[0]); + } + } + const domain = []; + for (let cond of searchConditions) { + let [tag, value] = cond.split(/:\s*/); + if (!this.validSearchTags.has(tag)) continue; + domain.push([this.fieldMap[tag], 'ilike', `%${value}%`]); + } + return domain; + } + _onClearSearch() { + this.orderManagementContext.searchString = ''; + this.onInputKeydown({ key: 'Enter' }); + } + } + OrderManagementControlPanel.template = 'OrderManagementControlPanel'; + + Registries.Component.add(OrderManagementControlPanel); + + return OrderManagementControlPanel; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js new file mode 100644 index 00000000..dcde9739 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js @@ -0,0 +1,101 @@ +odoo.define('point_of_sale.OrderManagementScreen', function (require) { + 'use strict'; + + const { useContext } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const Registries = require('point_of_sale.Registries'); + const OrderFetcher = require('point_of_sale.OrderFetcher'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const contexts = require('point_of_sale.PosContext'); + + class OrderManagementScreen extends ControlButtonsMixin(IndependentToOrderScreen) { + constructor() { + super(...arguments); + useListener('close-screen', this.close); + useListener('set-numpad-mode', this._setNumpadMode); + useListener('click-order', this._onClickOrder); + useListener('next-page', this._onNextPage); + useListener('prev-page', this._onPrevPage); + useListener('search', this._onSearch); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + useWithBarcode: true, + }); + this.numpadMode = 'quantity'; + OrderFetcher.setComponent(this); + OrderFetcher.setConfigId(this.env.pos.config_id); + this.orderManagementContext = useContext(contexts.orderManagement); + } + mounted() { + OrderFetcher.on('update', this, this.render); + this.env.pos.get('orders').on('add remove', this.render, this); + + // calculate how many can fit in the screen. + // It is based on the height of the header element. + // So the result is only accurate if each row is just single line. + const flexContainer = this.el.querySelector('.flex-container'); + const cpEl = this.el.querySelector('.control-panel'); + const headerEl = this.el.querySelector('.order-row.header'); + const val = Math.trunc( + (flexContainer.offsetHeight - cpEl.offsetHeight - headerEl.offsetHeight) / + headerEl.offsetHeight + ); + OrderFetcher.setNPerPage(val); + + // Fetch the order after mounting so that order management screen + // is shown while fetching. + setTimeout(() => OrderFetcher.fetch(), 0); + } + willUnmount() { + OrderFetcher.off('update', this); + this.env.pos.get('orders').off('add remove', null, this); + } + get selectedClient() { + const order = this.orderManagementContext.selectedOrder; + return order ? order.get_client() : null; + } + get orders() { + return OrderFetcher.get(); + } + async _setNumpadMode(event) { + const { mode } = event.detail; + this.numpadMode = mode; + NumberBuffer.reset(); + } + _onNextPage() { + OrderFetcher.nextPage(); + } + _onPrevPage() { + OrderFetcher.prevPage(); + } + _onSearch({ detail: domain }) { + OrderFetcher.setSearchDomain(domain); + OrderFetcher.setPage(1); + OrderFetcher.fetch(); + } + _onClickOrder({ detail: clickedOrder }) { + if (!clickedOrder || clickedOrder.locked) { + this.orderManagementContext.selectedOrder = clickedOrder; + } else { + this._setOrder(clickedOrder); + } + } + /** + * @param {models.Order} order + */ + _setOrder(order) { + this.env.pos.set_order(order); + if (order === this.env.pos.get_order()) { + this.close(); + } + } + } + OrderManagementScreen.template = 'OrderManagementScreen'; + OrderManagementScreen.hideOrderSelector = true; + + Registries.Component.add(OrderManagementScreen); + + return OrderManagementScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js new file mode 100644 index 00000000..959ea5a1 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js @@ -0,0 +1,42 @@ +odoo.define('point_of_sale.OrderRow', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props {models.Order} order + * @props columns + * @emits click-order + */ + class OrderRow extends PosComponent { + get order() { + return this.props.order; + } + get highlighted() { + const highlightedOrder = this.props.highlightedOrder; + return !highlightedOrder ? false : highlightedOrder.backendId === this.props.order.backendId; + } + + // Column getters // + + get name() { + return this.order.get_name(); + } + get date() { + return moment(this.order.validation_date).format('YYYY-MM-DD hh:mm A'); + } + get customer() { + const customer = this.order.get('client'); + return customer ? customer.name : null; + } + get total() { + return this.env.pos.format_currency(this.order.get_total_with_tax()); + } + } + OrderRow.template = 'OrderRow'; + + Registries.Component.add(OrderRow); + + return OrderRow; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js new file mode 100644 index 00000000..35f6ec5d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js @@ -0,0 +1,55 @@ +odoo.define('point_of_sale.OrderlineDetails', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { format } = require('web.field_utils'); + const { round_precision: round_pr } = require('web.utils'); + + /** + * @props {pos.order.line} line + */ + class OrderlineDetails extends PosComponent { + get line() { + const line = this.props.line; + const formatQty = (line) => { + const quantity = line.get_quantity(); + const unit = line.get_unit(); + const decimals = this.env.pos.dp['Product Unit of Measure']; + const rounding = Math.max(unit.rounding, Math.pow(10, -decimals)); + const roundedQuantity = round_pr(quantity, rounding); + return format.float(roundedQuantity, { digits: [69, decimals] }); + }; + return { + productName: line.get_full_product_name(), + totalPrice: line.get_price_with_tax(), + quantity: formatQty(line), + unit: line.get_unit().name, + unitPrice: line.get_unit_price(), + }; + } + get productName() { + return this.line.productName; + } + get totalPrice() { + return this.env.pos.format_currency(this.line.totalPrice); + } + get quantity() { + return this.line.quantity; + } + get unitPrice() { + return this.line.unitPrice; + } + get unit() { + return this.line.unit; + } + get pricePerUnit() { + return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`; + } + } + OrderlineDetails.template = 'OrderlineDetails'; + + Registries.Component.add(OrderlineDetails); + + return OrderlineDetails; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js new file mode 100644 index 00000000..7fcc514d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js @@ -0,0 +1,32 @@ +odoo.define('point_of_sale.ReprintReceiptScreen', function (require) { + 'use strict'; + + const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen'); + const Registries = require('point_of_sale.Registries'); + + const ReprintReceiptScreen = (AbstractReceiptScreen) => { + class ReprintReceiptScreen extends AbstractReceiptScreen { + mounted() { + this.printReceipt(); + } + confirm() { + this.showScreen('OrderManagementScreen'); + } + async printReceipt() { + if(this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen) { + let result = await this._printReceipt(); + if(result) + this.showScreen('OrderManagementScreen'); + } + } + async tryReprint() { + await this._printReceipt(); + } + } + ReprintReceiptScreen.template = 'ReprintReceiptScreen'; + return ReprintReceiptScreen; + }; + Registries.Component.addByExtending(ReprintReceiptScreen, AbstractReceiptScreen); + + return ReprintReceiptScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js new file mode 100644 index 00000000..b5dc6a7b --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js @@ -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; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js new file mode 100644 index 00000000..8e5853d3 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.PaymentMethodButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentMethodButton extends PosComponent {} + PaymentMethodButton.template = 'PaymentMethodButton'; + + Registries.Component.add(PaymentMethodButton); + + return PaymentMethodButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js new file mode 100644 index 00000000..6fe25a11 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js @@ -0,0 +1,376 @@ +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 } = require('point_of_sale.custom_hooks'); + const NumberBuffer = require('point_of_sale.NumberBuffer'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const { onChangeOrder } = require('point_of_sale.custom_hooks'); + + class PaymentScreen extends PosComponent { + constructor() { + super(...arguments); + useListener('delete-payment-line', this.deletePaymentLine); + useListener('select-payment-line', this.selectPaymentLine); + useListener('new-payment-line', this.addNewPaymentLine); + useListener('update-selected-paymentline', this._updateSelectedPaymentline); + useListener('send-payment-request', this._sendPaymentRequest); + useListener('send-payment-cancel', this._sendPaymentCancel); + useListener('send-payment-reverse', this._sendPaymentReverse); + useListener('send-force-done', this._sendForceDone); + NumberBuffer.use({ + // The numberBuffer listens to this event to update its state. + // Basically means 'update the buffer when this event is triggered' + nonKeyboardInputEvent: 'input-from-numpad', + // When the buffer is updated, trigger this event. + // Note that the component listens to it. + triggerAtInput: 'update-selected-paymentline', + }); + onChangeOrder(this._onPrevOrder, this._onNewOrder); + useErrorHandlers(); + this.payment_interface = null; + this.error = false; + this.payment_methods_from_config = this.env.pos.payment_methods.filter(method => this.env.pos.config.payment_method_ids.includes(method.id)); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get paymentLines() { + return this.currentOrder.get_paymentlines(); + } + get selectedPaymentLine() { + return this.currentOrder.selected_paymentline; + } + async selectClient() { + // IMPROVEMENT: This code snippet is repeated multiple times. + // Maybe it's better to create a function for it. + const currentClient = this.currentOrder.get_client(); + const { confirmed, payload: newClient } = await this.showTempScreen( + 'ClientListScreen', + { client: currentClient } + ); + if (confirmed) { + this.currentOrder.set_client(newClient); + this.currentOrder.updatePricelist(newClient); + } + } + addNewPaymentLine({ detail: paymentMethod }) { + // original function: click_paymentmethods + if (this.currentOrder.electronic_payment_in_progress()) { + this.showPopup('ErrorPopup', { + title: this.env._t('Error'), + body: this.env._t('There is already an electronic payment in progress.'), + }); + return false; + } else { + this.currentOrder.add_paymentline(paymentMethod); + NumberBuffer.reset(); + this.payment_interface = paymentMethod.payment_terminal; + if (this.payment_interface) { + this.currentOrder.selected_paymentline.set_payment_status('pending'); + } + return true; + } + } + _updateSelectedPaymentline() { + if (this.paymentLines.every((line) => line.paid)) { + this.currentOrder.add_paymentline(this.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 + if ( + this.payment_interface && + !['pending', 'retry'].includes(this.selectedPaymentLine.get_payment_status()) + ) { + return; + } + if (NumberBuffer.get() === null) { + this.deletePaymentLine({ detail: { cid: this.selectedPaymentLine.cid } }); + } else { + this.selectedPaymentLine.set_amount(NumberBuffer.getFloat()); + } + } + toggleIsToInvoice() { + // click_invoice + this.currentOrder.set_to_invoice(!this.currentOrder.is_to_invoice()); + this.render(); + } + openCashbox() { + this.env.pos.proxy.printer.open_cashbox(); + } + async addTip() { + // click_tip + const tip = this.currentOrder.get_tip(); + const change = this.currentOrder.get_change(); + let value = tip.toFixed(this.env.pos.decimals); + + if (tip === 0 && change > 0) { + value = change; + } + + const { confirmed, payload } = await this.showPopup('NumberPopup', { + title: tip ? this.env._t('Change Tip') : this.env._t('Add Tip'), + startingValue: value, + }); + + if (confirmed) { + this.currentOrder.set_tip(parse.float(payload)); + } + } + deletePaymentLine(event) { + const { cid } = event.detail; + const line = this.paymentLines.find((line) => line.cid === cid); + + // If a paymentline with a payment terminal linked to + // it is removed, the terminal should get a cancel + // request. + if (['waiting', 'waitingCard', 'timeout'].includes(line.get_payment_status())) { + line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid); + } + + this.currentOrder.remove_paymentline(line); + NumberBuffer.reset(); + this.render(); + } + selectPaymentLine(event) { + const { cid } = event.detail; + const line = this.paymentLines.find((line) => line.cid === cid); + this.currentOrder.select_paymentline(line); + NumberBuffer.reset(); + this.render(); + } + async validateOrder(isForceValidate) { + if(this.env.pos.config.cash_rounding) { + if(!this.env.pos.get_order().check_paymentlines_rounding()) { + this.showPopup('ErrorPopup', { + title: this.env._t('Rounding error in payment lines'), + body: this.env._t("The amount of your payment lines must be rounded to validate the transaction."), + }); + 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 _finalizeValidation() { + if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.env.pos.config.iface_cashdrawer) { + this.env.pos.proxy.printer.open_cashbox(); + } + + this.currentOrder.initialize_validation_date(); + this.currentOrder.finalized = true; + + let syncedOrderBackendIds = []; + + try { + if (this.currentOrder.is_to_invoice()) { + syncedOrderBackendIds = await this.env.pos.push_and_invoice_order( + this.currentOrder + ); + } else { + syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder); + } + } catch (error) { + if (error.code == 700) + this.error = true; + if (error instanceof Error) { + throw error; + } else { + await this._handlePushOrderError(error); + } + } + if (syncedOrderBackendIds.length && this.currentOrder.wait_for_push_order()) { + const result = await this._postPushOrderResolve( + this.currentOrder, + syncedOrderBackendIds + ); + if (!result) { + await this.showPopup('ErrorPopup', { + title: 'Error: no internet connection.', + body: error, + }); + } + } + + this.showScreen(this.nextScreen); + + // If we succeeded in syncing the current order, and + // there are still other orders that are left unsynced, + // we ask the user if he is willing to wait and sync them. + if (syncedOrderBackendIds.length && this.env.pos.db.get_orders().length) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Remaining unsynced orders'), + body: this.env._t( + 'There are unsynced orders. Do you want to sync these orders?' + ), + }); + if (confirmed) { + // NOTE: Not yet sure if this should be awaited or not. + // If awaited, some operations like changing screen + // might not work. + this.env.pos.push_orders(); + } + } + } + get nextScreen() { + return !this.error? 'ReceiptScreen' : 'ProductScreen'; + } + async _isOrderValid(isForceValidate) { + if (this.currentOrder.get_orderlines().length === 0) { + this.showPopup('ErrorPopup', { + title: this.env._t('Empty Order'), + body: this.env._t( + 'There must be at least one product in your order before it can be validated' + ), + }); + return false; + } + + if (this.currentOrder.is_to_invoice() && !this.currentOrder.get_client()) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Please select the Customer'), + body: this.env._t( + 'You need to select the customer before you can invoice an order.' + ), + }); + if (confirmed) { + this.selectClient(); + } + return false; + } + + if (!this.currentOrder.is_paid() || this.invoicing) { + return false; + } + + if (this.currentOrder.has_not_valid_rounding()) { + var line = this.currentOrder.has_not_valid_rounding(); + this.showPopup('ErrorPopup', { + title: this.env._t('Incorrect rounding'), + body: this.env._t( + 'You have to round your payments lines.' + line.amount + ' is not rounded.' + ), + }); + return false; + } + + // The exact amount must be paid if there is no cash payment method defined. + if ( + Math.abs( + this.currentOrder.get_total_with_tax() - this.currentOrder.get_total_paid() + this.currentOrder.get_rounding_applied() + ) > 0.00001 + ) { + var cash = false; + for (var i = 0; i < this.env.pos.payment_methods.length; i++) { + cash = cash || this.env.pos.payment_methods[i].is_cash_count; + } + if (!cash) { + this.showPopup('ErrorPopup', { + title: this.env._t('Cannot return change without a cash payment method'), + body: this.env._t( + 'There is no cash payment method available in this point of sale to handle the change.\n\n Please pay the exact amount or add a cash payment method in the point of sale configuration' + ), + }); + return false; + } + } + + // if the change is too large, it's probably an input error, make the user confirm. + if ( + !isForceValidate && + this.currentOrder.get_total_with_tax() > 0 && + this.currentOrder.get_total_with_tax() * 1000 < this.currentOrder.get_total_paid() + ) { + this.showPopup('ConfirmPopup', { + title: this.env._t('Please Confirm Large Amount'), + body: + this.env._t('Are you sure that the customer wants to pay') + + ' ' + + this.env.pos.format_currency(this.currentOrder.get_total_paid()) + + ' ' + + this.env._t('for an order of') + + ' ' + + this.env.pos.format_currency(this.currentOrder.get_total_with_tax()) + + ' ' + + this.env._t('? Clicking "Confirm" will validate the payment.'), + }).then(({ confirmed }) => { + if (confirmed) this.validateOrder(true); + }); + return false; + } + + return true; + } + async _postPushOrderResolve(order, order_server_ids) { + return true; + } + async _sendPaymentRequest({ detail: line }) { + // Other payment lines can not be reversed anymore + this.paymentLines.forEach(function (line) { + line.can_be_reversed = false; + }); + + const payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('waiting'); + + const isPaymentSuccessful = await payment_terminal.send_payment_request(line.cid); + if (isPaymentSuccessful) { + line.set_payment_status('done'); + line.can_be_reversed = this.payment_interface.supports_reversals; + } else { + line.set_payment_status('retry'); + } + } + async _sendPaymentCancel({ detail: line }) { + const payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('waitingCancel'); + 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'); + } + _onPrevOrder(prevOrder) { + prevOrder.off('change', null, this); + prevOrder.paymentlines.off('change', null, this); + if (prevOrder) { + prevOrder.stop_electronic_payment(); + } + } + async _onNewOrder(newOrder) { + newOrder.on('change', this.render, this); + newOrder.paymentlines.on('change', this.render, this); + NumberBuffer.reset(); + await this.render(); + } + } + PaymentScreen.template = 'PaymentScreen'; + + Registries.Component.add(PaymentScreen); + + return PaymentScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js new file mode 100644 index 00000000..6cafac15 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js @@ -0,0 +1,23 @@ +odoo.define('point_of_sale.PaymentScreenElectronicPayment', function (require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentScreenElectronicPayment extends PosComponent { + mounted() { + this.props.line.on('change', this.render, this); + } + willUnmount() { + if (this.props.line) { + // It could be that the line is deleted before unmounting the element. + this.props.line.off('change', null, this); + } + } + } + PaymentScreenElectronicPayment.template = 'PaymentScreenElectronicPayment'; + + Registries.Component.add(PaymentScreenElectronicPayment); + + return PaymentScreenElectronicPayment; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js new file mode 100644 index 00000000..e661722f --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.PaymentScreenNumpad', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class PaymentScreenNumpad extends PosComponent { + constructor() { + super(...arguments); + this.decimalPoint = this.env._t.database.parameters.decimal_point; + } + } + PaymentScreenNumpad.template = 'PaymentScreenNumpad'; + + Registries.Component.add(PaymentScreenNumpad); + + return PaymentScreenNumpad; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js new file mode 100644 index 00000000..8f231146 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js @@ -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; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js new file mode 100644 index 00000000..12ccaa84 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js @@ -0,0 +1,30 @@ +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.currentOrder.get_change()); + } + get totalDueText() { + return this.env.pos.format_currency( + this.currentOrder.get_total_with_tax() + this.currentOrder.get_rounding_applied() + ); + } + get remainingText() { + return this.env.pos.format_currency( + this.currentOrder.get_due() > 0 ? this.currentOrder.get_due() : 0 + ); + } + get currentOrder() { + return this.env.pos.get_order(); + } + } + PaymentScreenStatus.template = 'PaymentScreenStatus'; + + Registries.Component.add(PaymentScreenStatus); + + return PaymentScreenStatus; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js new file mode 100644 index 00000000..d30fe85e --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js @@ -0,0 +1,25 @@ +odoo.define('point_of_sale.ActionpadWidget', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * @props client + * @emits click-customer + * @emits click-pay + */ + class ActionpadWidget extends PosComponent { + get isLongName() { + return this.client && this.client.name.length > 10; + } + get client() { + return this.props.client; + } + } + ActionpadWidget.template = 'ActionpadWidget'; + + Registries.Component.add(ActionpadWidget); + + return ActionpadWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js new file mode 100644 index 00000000..be42e45d --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CashBoxOpening.js @@ -0,0 +1,42 @@ +odoo.define('point_of_sale.CashBoxOpening', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { Gui } = require('point_of_sale.Gui'); + + class CashBoxOpening extends PosComponent { + constructor() { + super(...arguments); + this.changes = {}; + this.defaultValue = this.env.pos.bank_statement.balance_start || 0; + this.symbol = this.env.pos.currency.symbol; + } + captureChange(event) { + this.changes[event.target.name] = event.target.value; + } + startSession() { + let cashOpening = this.changes.cashBoxValue? this.changes.cashBoxValue: this.defaultValue; + if(isNaN(cashOpening)) { + Gui.showPopup('ErrorPopup',{ + 'title': 'Wrong value', + 'body': 'Please insert a correct value.', + }); + return; + } + this.env.pos.bank_statement.balance_start = cashOpening; + this.env.pos.pos_session.state = 'opened'; + this.props.cashControl.cashControl = false; + this.rpc({ + model: 'pos.session', + method: 'set_cashbox_pos', + args: [this.env.pos.pos_session.id, cashOpening, this.changes.notes], + }); + } + } + CashBoxOpening.template = 'CashBoxOpening'; + + Registries.Component.add(CashBoxOpening); + + return CashBoxOpening; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js new file mode 100644 index 00000000..843cc248 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.CategoryBreadcrumb', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CategoryBreadcrumb extends PosComponent {} + CategoryBreadcrumb.template = 'CategoryBreadcrumb'; + + Registries.Component.add(CategoryBreadcrumb); + + return CategoryBreadcrumb; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js new file mode 100644 index 00000000..05914bec --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.CategoryButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CategoryButton extends PosComponent { + get imageUrl() { + const category = this.props.category + return `/web/image?model=pos.category&field=image_128&id=${category.id}&write_date=${category.write_date}&unique=1`; + } + } + CategoryButton.template = 'CategoryButton'; + + Registries.Component.add(CategoryButton); + + return CategoryButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js new file mode 100644 index 00000000..675512d8 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.CategorySimpleButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CategorySimpleButton extends PosComponent {} + CategorySimpleButton.template = 'CategorySimpleButton'; + + Registries.Component.add(CategorySimpleButton); + + return CategorySimpleButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js new file mode 100644 index 00000000..901e70e7 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js @@ -0,0 +1,80 @@ +odoo.define('point_of_sale.SetFiscalPositionButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ProductScreen = require('point_of_sale.ProductScreen'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class SetFiscalPositionButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + mounted() { + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get currentFiscalPositionName() { + return this.currentOrder && this.currentOrder.fiscal_position + ? this.currentOrder.fiscal_position.display_name + : this.env._t('Tax'); + } + async onClick() { + const currentFiscalPosition = this.currentOrder.fiscal_position; + const fiscalPosList = [ + { + id: -1, + label: this.env._t('None'), + isSelected: !currentFiscalPosition, + }, + ]; + for (let fiscalPos of this.env.pos.fiscal_positions) { + fiscalPosList.push({ + id: fiscalPos.id, + label: fiscalPos.name, + isSelected: currentFiscalPosition + ? fiscalPos.id === currentFiscalPosition.id + : false, + item: fiscalPos, + }); + } + const { confirmed, payload: selectedFiscalPosition } = await this.showPopup( + 'SelectionPopup', + { + title: this.env._t('Select Fiscal Position'), + list: fiscalPosList, + } + ); + if (confirmed) { + this.currentOrder.fiscal_position = selectedFiscalPosition; + // IMPROVEMENT: The following is the old implementation and I believe + // there could be a better way of doing it. + for (let line of this.currentOrder.orderlines.models) { + line.set_quantity(line.quantity); + } + this.currentOrder.trigger('change'); + } + } + } + SetFiscalPositionButton.template = 'SetFiscalPositionButton'; + + ProductScreen.addControlButton({ + component: SetFiscalPositionButton, + condition: function() { + return this.env.pos.fiscal_positions.length > 0; + }, + position: ['before', 'SetPricelistButton'], + }); + + Registries.Component.add(SetFiscalPositionButton); + + return SetFiscalPositionButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js new file mode 100644 index 00000000..c0a01f87 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js @@ -0,0 +1,67 @@ +odoo.define('point_of_sale.SetPricelistButton', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const ProductScreen = require('point_of_sale.ProductScreen'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class SetPricelistButton extends PosComponent { + constructor() { + super(...arguments); + useListener('click', this.onClick); + } + mounted() { + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + get currentOrder() { + return this.env.pos.get_order(); + } + get currentPricelistName() { + const order = this.currentOrder; + return order && order.pricelist + ? order.pricelist.display_name + : this.env._t('Pricelist'); + } + async onClick() { + // Create the list to be passed to the SelectionPopup. + // Pricelist object is passed as item in the list because it + // is the object that will be returned when the popup is confirmed. + const selectionList = this.env.pos.pricelists.map(pricelist => ({ + id: pricelist.id, + label: pricelist.name, + isSelected: pricelist.id === this.currentOrder.pricelist.id, + item: pricelist, + })); + + const { confirmed, payload: selectedPricelist } = await this.showPopup( + 'SelectionPopup', + { + title: this.env._t('Select the pricelist'), + list: selectionList, + } + ); + + if (confirmed) { + this.currentOrder.set_pricelist(selectedPricelist); + } + } + } + SetPricelistButton.template = 'SetPricelistButton'; + + ProductScreen.addControlButton({ + component: SetPricelistButton, + condition: function() { + return this.env.pos.config.use_pricelist && this.env.pos.pricelists.length > 1; + }, + }); + + Registries.Component.add(SetPricelistButton); + + return SetPricelistButton; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js new file mode 100644 index 00000000..28641236 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/HomeCategoryBreadcrumb.js @@ -0,0 +1,47 @@ +odoo.define('point_of_sale.HomeCategoryBreadcrumb', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + const { useListener } = require('web.custom_hooks'); + + class HomeCategoryBreadcrumb extends PosComponent { + constructor() { + super(...arguments); + useListener('categ-popup', this._categPopup); + } + get selectedCategoryId() { + return this.env.pos.get('selectedCategoryId'); + } + async _categPopup() { + let selectionList = [{ + id: 0, + label:'All Items', + isSelected: 0 === this.env.pos.get('selectedCategoryId'), + item: {id:0,name:'All Items'}, + }]; + let subs = this.props.subcategories.map(category => ({ + id: category.id, + label: category.name, + isSelected: category.id === this.env.pos.get('selectedCategoryId'), + item: category, + })); + selectionList = selectionList.concat(subs); + const { confirmed, payload: selectedCategory } = await this.showPopup( + 'SelectionPopup', + { + title: this.env._t('Select the category'), + list: selectionList, + } + ); + if (confirmed) { + this.trigger('switch-category', selectedCategory.id); + } + } + } + HomeCategoryBreadcrumb.template = 'HomeCategoryBreadcrumb'; + + Registries.Component.add(HomeCategoryBreadcrumb); + + return HomeCategoryBreadcrumb; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js new file mode 100644 index 00000000..5850dc83 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js @@ -0,0 +1,59 @@ +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 {'quantiy' | 'price' | 'discount'} activeMode + * @event set-numpad-mode - triggered when mode button is clicked + * @event numpad-click-input - triggered when numpad button is clicked + * + * IMPROVEMENT: Whenever new-orderline-selected is triggered, + * numpad mode should be set to 'quantity'. Now that the mode state + * is lifted to the parent component, this improvement can be done in + * the parent component. + */ + class NumpadWidget extends PosComponent { + mounted() { + // IMPROVEMENT: This listener shouldn't be here because in core point_of_sale + // there is no way of changing the cashier. Only when pos_hr is installed + // that this listener makes sense. + this.env.pos.on('change:cashier', () => { + if (!this.hasPriceControlRights && this.props.activeMode === 'price') { + this.trigger('set-numpad-mode', { mode: 'quantity' }); + } + }); + } + willUnmount() { + this.env.pos.on('change:cashier', null, this); + } + get hasPriceControlRights() { + const cashier = this.env.pos.get('cashier') || this.env.pos.get_cashier(); + return !this.env.pos.config.restrict_price_control || cashier.role == 'manager'; + } + get hasManualDiscount() { + return this.env.pos.config.manual_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'; + + Registries.Component.add(NumpadWidget); + + return NumpadWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js new file mode 100644 index 00000000..aeb9891f --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.OrderSummary', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class OrderSummary extends PosComponent {} + OrderSummary.template = 'OrderSummary'; + + Registries.Component.add(OrderSummary); + + return OrderSummary; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js new file mode 100644 index 00000000..ee610afd --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js @@ -0,0 +1,110 @@ +odoo.define('point_of_sale.OrderWidget', function(require) { + 'use strict'; + + const { useState, useRef, onPatched } = owl.hooks; + const { useListener } = require('web.custom_hooks'); + const { onChangeOrder } = require('point_of_sale.custom_hooks'); + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class OrderWidget extends PosComponent { + constructor() { + super(...arguments); + useListener('select-line', this._selectLine); + useListener('edit-pack-lot-lines', this._editPackLotLines); + onChangeOrder(this._onPrevOrder, this._onNewOrder); + this.scrollableRef = useRef('scrollable'); + this.scrollToBottom = false; + onPatched(() => { + // IMPROVEMENT + // This one just stays at the bottom of the orderlines list. + // Perhaps it is better to scroll to the added or modified orderline. + if (this.scrollToBottom) { + this.scrollableRef.el.scrollTop = this.scrollableRef.el.scrollHeight; + this.scrollToBottom = false; + } + }); + this.state = useState({ total: 0, tax: 0 }); + this._updateSummary(); + } + get order() { + return this.env.pos.get_order(); + } + get orderlinesArray() { + return this.order ? this.order.get_orderlines() : []; + } + _selectLine(event) { + this.order.select_orderline(event.detail.orderline); + } + // IMPROVEMENT: Might be better to lift this to ProductScreen + // because there is similar operation when clicking a product. + // + // Furthermore, what if a number different from 1 (or -1) is specified + // to an orderline that has product tracked by lot. Lot tracking (based + // on the current implementation) requires that 1 item per orderline is + // allowed. + async _editPackLotLines(event) { + const orderline = event.detail.orderline; + const isAllowOnlyOneLot = orderline.product.isAllowOnlyOneLot(); + const packLotLinesToEdit = orderline.getPackLotLinesToEdit(isAllowOnlyOneLot); + const { confirmed, payload } = await this.showPopup('EditListPopup', { + title: this.env._t('Lot/Serial Number(s) Required'), + isSingleItem: isAllowOnlyOneLot, + array: packLotLinesToEdit, + }); + if (confirmed) { + // Segregate the old and new packlot lines + const modifiedPackLotLines = Object.fromEntries( + payload.newArray.filter(item => item.id).map(item => [item.id, item.text]) + ); + const newPackLotLines = payload.newArray + .filter(item => !item.id) + .map(item => ({ lot_name: item.text })); + + orderline.setPackLotLines({ modifiedPackLotLines, newPackLotLines }); + } + this.order.select_orderline(event.detail.orderline); + } + _onNewOrder(order) { + if (order) { + order.orderlines.on( + 'new-orderline-selected', + () => this.trigger('new-orderline-selected'), + this + ); + order.orderlines.on('change', this._updateSummary, this); + order.orderlines.on( + 'add remove', + () => { + this.scrollToBottom = true; + this._updateSummary(); + }, + this + ); + order.on('change', this.render, this); + } + this._updateSummary(); + this.trigger('new-orderline-selected'); + } + _onPrevOrder(order) { + if (order) { + order.orderlines.off('new-orderline-selected', null, this); + order.orderlines.off('change', null, this); + order.orderlines.off('add remove', null, this); + order.off('change', null, this); + } + } + _updateSummary() { + const total = this.order ? this.order.get_total_with_tax() : 0; + const tax = this.order ? total - this.order.get_total_without_tax() : 0; + this.state.total = this.env.pos.format_currency(total); + this.state.tax = this.env.pos.format_currency(tax); + this.render(); + } + } + OrderWidget.template = 'OrderWidget'; + + Registries.Component.add(OrderWidget); + + return OrderWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js new file mode 100644 index 00000000..71a96bd4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js @@ -0,0 +1,25 @@ +odoo.define('point_of_sale.Orderline', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class Orderline extends PosComponent { + selectLine() { + this.trigger('select-line', { orderline: this.props.line }); + } + lotIconClicked() { + this.trigger('edit-pack-lot-lines', { orderline: this.props.line }); + } + get addedClasses() { + return { + selected: this.props.line.selected, + }; + } + } + Orderline.template = 'Orderline'; + + Registries.Component.add(Orderline); + + return Orderline; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js new file mode 100644 index 00000000..ac93500c --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js @@ -0,0 +1,49 @@ +odoo.define('point_of_sale.ProductItem', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ProductItem extends PosComponent { + /** + * For accessibility, pressing <space> should be like clicking the product. + * <enter> is not considered because it conflicts with the barcode. + * + * @param {KeyPressEvent} event + */ + spaceClickProduct(event) { + if (event.which === 32) { + this.trigger('click-product', this.props.product); + } + } + get imageUrl() { + const product = this.props.product; + return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + } + get pricelist() { + const current_order = this.env.pos.get_order(); + if (current_order) { + return current_order.pricelist; + } + return this.env.pos.default_pricelist; + } + get price() { + const formattedUnitPrice = this.env.pos.format_currency( + this.props.product.get_price(this.pricelist, 1), + 'Product Price' + ); + if (this.props.product.to_weight) { + return `${formattedUnitPrice}/${ + this.env.pos.units_by_id[this.props.product.uom_id[0]].name + }`; + } else { + return formattedUnitPrice; + } + } + } + ProductItem.template = 'ProductItem'; + + Registries.Component.add(ProductItem); + + return ProductItem; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js new file mode 100644 index 00000000..aeee2ede --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.ProductList', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ProductList extends PosComponent {} + ProductList.template = 'ProductList'; + + Registries.Component.add(ProductList); + + return ProductList; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js new file mode 100644 index 00000000..65daa7cc --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js @@ -0,0 +1,327 @@ +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.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks'); + const { useState } = owl.hooks; + const { parse } = require('web.field_utils'); + + class ProductScreen extends ControlButtonsMixin(PosComponent) { + constructor() { + super(...arguments); + useListener('update-selected-orderline', this._updateSelectedOrderline); + useListener('new-orderline-selected', this._newOrderlineSelected); + useListener('set-numpad-mode', this._setNumpadMode); + useListener('click-product', this._clickProduct); + useListener('click-customer', this._onClickCustomer); + useListener('click-pay', this._onClickPay); + useBarcodeReader({ + product: this._barcodeProductAction, + weight: this._barcodeProductAction, + price: this._barcodeProductAction, + client: this._barcodeClientAction, + discount: this._barcodeDiscountAction, + error: this._barcodeErrorAction, + }) + onChangeOrder(null, (newOrder) => newOrder && this.render()); + NumberBuffer.use({ + nonKeyboardInputEvent: 'numpad-click-input', + triggerAtInput: 'update-selected-orderline', + useWithBarcode: true, + }); + let status = this.showCashBoxOpening() + this.state = useState({ cashControl: status, numpadMode: 'quantity' }); + this.mobile_pane = this.props.mobile_pane || 'right'; + } + mounted() { + this.env.pos.on('change:selectedClient', this.render, this); + } + willUnmount() { + this.env.pos.off('change:selectedClient', null, this); + } + /** + * To be overridden by modules that checks availability of + * connected scale. + * @see _onScaleNotAvailable + */ + get isScaleAvailable() { + return true; + } + get client() { + return this.env.pos.get_client(); + } + get currentOrder() { + return this.env.pos.get_order(); + } + showCashBoxOpening() { + if(this.env.pos.config.cash_control && this.env.pos.pos_session.state == 'opening_control') + return true; + return false; + } + async _getAddProductOptions(product) { + let price_extra = 0.0; + let draftPackLotLines, weight, description, packLotLinesToEdit; + + if (this.env.pos.config.product_configurator && _.some(product.attribute_line_ids, (id) => id in this.env.pos.attributes_by_ptal_id)) { + let attributes = _.map(product.attribute_line_ids, (id) => this.env.pos.attributes_by_ptal_id[id]) + .filter((attr) => attr !== undefined); + let { confirmed, payload } = await this.showPopup('ProductConfiguratorPopup', { + product: product, + attributes: attributes, + }); + + 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 = []; + } + } + 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(); + } + } + + return { draftPackLotLines, quantity: weight, description, price_extra }; + } + async _clickProduct(event) { + if (!this.currentOrder) { + this.env.pos.add_new_order(); + } + const product = event.detail; + const options = await this._getAddProductOptions(product); + // Do not add product if options is undefined. + if (!options) return; + // Add the product after having the extra information. + this.currentOrder.add_product(product, options); + NumberBuffer.reset(); + } + _setNumpadMode(event) { + const { mode } = event.detail; + NumberBuffer.capture(); + NumberBuffer.reset(); + this.state.numpadMode = mode; + } + async _updateSelectedOrderline(event) { + if(this.state.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) { + let order = this.env.pos.get_order(); + let selectedLine = order.get_selected_orderline(); + let lastId = order.orderlines.last().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); + } + } + async _newOrderlineSelected() { + NumberBuffer.reset(); + } + _setValue(val) { + if (this.currentOrder.get_selected_orderline()) { + if (this.state.numpadMode === 'quantity') { + this.currentOrder.get_selected_orderline().set_quantity(val); + } else if (this.state.numpadMode === 'discount') { + this.currentOrder.get_selected_orderline().set_discount(val); + } else if (this.state.numpadMode === 'price') { + var selected_orderline = this.currentOrder.get_selected_orderline(); + selected_orderline.price_manually_set = true; + selected_orderline.set_unit_price(val); + } + if (this.env.pos.config.iface_customer_facing_display) { + this.env.pos.send_current_order_to_customer_facing_display(); + } + } + } + async _barcodeProductAction(code) { + const product = this.env.pos.db.get_product_by_barcode(code.base_code) + if (!product) { + return this._barcodeErrorAction(code); + } + const options = await this._getAddProductOptions(product); + // 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 }); + } else if (code.type === 'weight') { + 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) + } + _barcodeClientAction(code) { + const partner = this.env.pos.db.get_partner_by_barcode(code.code); + if (partner) { + if (this.currentOrder.get_client() !== partner) { + this.currentOrder.set_client(partner); + this.currentOrder.set_pricelist( + _.findWhere(this.env.pos.pricelists, { + id: partner.property_product_pricelist[0], + }) || this.env.pos.default_pricelist + ); + } + return true; + } + this._barcodeErrorAction(code); + return false; + } + _barcodeDiscountAction(code) { + var last_orderline = this.currentOrder.get_last_orderline(); + if (last_orderline) { + last_orderline.set_discount(code.value); + } + } + // IMPROVEMENT: The following two methods should be in PosScreenComponent? + // Why? Because once we start declaring barcode actions in different + // screens, these methods will also be declared over and over. + _barcodeErrorAction(code) { + this.showPopup('ErrorBarcodePopup', { code: this._codeRepr(code) }); + } + _codeRepr(code) { + if (code.code.length > 32) { + return code.code.substring(0, 29) + '...'; + } else { + return code.code; + } + } + /** + * override this method to perform procedure if the scale is not available. + * @see isScaleAvailable + */ + async _onScaleNotAvailable() {} + async _showDecreaseQuantityPopup() { + const { confirmed, payload: inputNumber } = await this.showPopup('NumberPopup', { + startingValue: 0, + title: this.env._t('Set the new quantity'), + }); + let newQuantity = 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); + } + } + } + async _onClickCustomer() { + // IMPROVEMENT: This code snippet is very similar to selectClient of PaymentScreen. + const currentClient = this.currentOrder.get_client(); + const { confirmed, payload: newClient } = await this.showTempScreen( + 'ClientListScreen', + { client: currentClient } + ); + if (confirmed) { + this.currentOrder.set_client(newClient); + this.currentOrder.updatePricelist(newClient); + } + } + _onClickPay() { + this.showScreen('PaymentScreen'); + } + switchPane() { + if (this.mobile_pane === "left") { + this.mobile_pane = "right"; + this.render(); + } + else { + this.mobile_pane = "left"; + this.render(); + } + } + } + ProductScreen.template = 'ProductScreen'; + + Registries.Component.add(ProductScreen); + + return ProductScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js new file mode 100644 index 00000000..17481058 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js @@ -0,0 +1,88 @@ +odoo.define('point_of_sale.ProductsWidget', function(require) { + 'use strict'; + + const { useState } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const { useListener } = require('web.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + + class ProductsWidget extends PosComponent { + /** + * @param {Object} props + * @param {number?} props.startCategoryId + */ + constructor() { + super(...arguments); + useListener('switch-category', this._switchCategory); + useListener('update-search', this._updateSearch); + useListener('try-add-product', this._tryAddProduct); + useListener('clear-search', this._clearSearch); + this.state = useState({ searchWord: '' }); + } + mounted() { + this.env.pos.on('change:selectedCategoryId', this.render, this); + } + willUnmount() { + this.env.pos.off('change:selectedCategoryId', null, this); + } + get selectedCategoryId() { + return this.env.pos.get('selectedCategoryId'); + } + get searchWord() { + return this.state.searchWord.trim(); + } + get productsToDisplay() { + if (this.searchWord !== '') { + return this.env.pos.db.search_product_in_category( + this.selectedCategoryId, + this.searchWord + ); + } else { + return this.env.pos.db.get_product_by_category(this.selectedCategoryId); + } + } + get subcategories() { + return this.env.pos.db + .get_category_childs_ids(this.selectedCategoryId) + .map(id => this.env.pos.db.get_category_by_id(id)); + } + get breadcrumbs() { + if (this.selectedCategoryId === this.env.pos.db.root_category_id) return []; + return [ + ...this.env.pos.db + .get_category_ancestors_ids(this.selectedCategoryId) + .slice(1), + this.selectedCategoryId, + ].map(id => this.env.pos.db.get_category_by_id(id)); + } + get hasNoCategories() { + return this.env.pos.db.get_category_childs_ids(0).length === 0; + } + _switchCategory(event) { + this.env.pos.set('selectedCategoryId', event.detail); + } + _updateSearch(event) { + this.state.searchWord = event.detail; + } + _tryAddProduct(event) { + const searchResults = this.productsToDisplay; + // If the search result contains one item, add the product and clear the search. + if (searchResults.length === 1) { + const { searchWordInput } = event.detail; + this.trigger('click-product', searchResults[0]); + // the value of the input element is not linked to the searchWord state, + // so we clear both the state and the element's value. + searchWordInput.el.value = ''; + this._clearSearch(); + } + } + _clearSearch() { + this.state.searchWord = ''; + } + } + ProductsWidget.template = 'ProductsWidget'; + + Registries.Component.add(ProductsWidget); + + return ProductsWidget; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js new file mode 100644 index 00000000..fc2df5b0 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js @@ -0,0 +1,33 @@ +odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) { + 'use strict'; + + const { useRef } = owl.hooks; + const { debounce } = owl.utils; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class ProductsWidgetControlPanel extends PosComponent { + constructor() { + super(...arguments); + this.searchWordInput = useRef('search-word-input'); + this.updateSearch = debounce(this.updateSearch, 100); + } + clearSearch() { + this.searchWordInput.el.value = ''; + this.trigger('clear-search'); + } + updateSearch(event) { + this.trigger('update-search', event.target.value); + if (event.key === 'Enter') { + // We are passing the searchWordInput ref so that when necessary, + // it can be modified by the parent. + this.trigger('try-add-product', { searchWordInput: this.searchWordInput }); + } + } + } + ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel'; + + Registries.Component.add(ProductsWidgetControlPanel); + + return ProductsWidgetControlPanel; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js new file mode 100644 index 00000000..c06b6339 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js @@ -0,0 +1,47 @@ +odoo.define('point_of_sale.OrderReceipt', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class OrderReceipt extends PosComponent { + constructor() { + super(...arguments); + this._receiptEnv = this.props.order.getOrderReceiptEnv(); + } + willUpdateProps(nextProps) { + this._receiptEnv = nextProps.order.getOrderReceiptEnv(); + } + get receipt() { + return this.receiptEnv.receipt; + } + get orderlines() { + return this.receiptEnv.orderlines; + } + get paymentlines() { + return this.receiptEnv.paymentlines; + } + get isTaxIncluded() { + return Math.abs(this.receipt.subtotal - this.receipt.total_with_tax) <= 0.000001; + } + get receiptEnv () { + return this._receiptEnv; + } + isSimple(line) { + return ( + line.discount === 0 && + line.is_in_unit && + line.quantity === 1 && + !( + line.display_discount_policy == 'without_discount' && + line.price < line.price_lst + ) + ); + } + } + OrderReceipt.template = 'OrderReceipt'; + + Registries.Component.add(OrderReceipt); + + return OrderReceipt; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js new file mode 100644 index 00000000..720c65e4 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -0,0 +1,123 @@ +odoo.define('point_of_sale.ReceiptScreen', function (require) { + 'use strict'; + + const { Printer } = require('point_of_sale.Printer'); + const { is_email } = require('web.utils'); + const { useRef, useContext } = owl.hooks; + const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks'); + const Registries = require('point_of_sale.Registries'); + const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen'); + + const ReceiptScreen = (AbstractReceiptScreen) => { + class ReceiptScreen extends AbstractReceiptScreen { + constructor() { + super(...arguments); + useErrorHandlers(); + onChangeOrder(null, (newOrder) => newOrder && this.render()); + this.orderReceipt = useRef('order-receipt'); + const order = this.currentOrder; + const client = order.get_client(); + this.orderUiState = useContext(order.uiState.ReceiptScreen); + this.orderUiState.inputEmail = this.orderUiState.inputEmail || (client && client.email) || ''; + this.is_email = is_email; + } + mounted() { + // Here, we send a task to the event loop that handles + // the printing of the receipt when the component is mounted. + // We are doing this because we want the receipt screen to be + // displayed regardless of what happen to the handleAutoPrint + // call. + setTimeout(async () => await this.handleAutoPrint(), 0); + } + async onSendEmail() { + if (!is_email(this.orderUiState.inputEmail)) { + this.orderUiState.emailSuccessful = false; + this.orderUiState.emailNotice = this.env._t('Invalid email.'); + return; + } + try { + await this._sendReceiptToCustomer(); + this.orderUiState.emailSuccessful = true; + this.orderUiState.emailNotice = this.env._t('Email sent.'); + } catch (error) { + this.orderUiState.emailSuccessful = false; + this.orderUiState.emailNotice = this.env._t('Sending email failed. Please try again.'); + } + } + get orderAmountPlusTip() { + const order = this.currentOrder; + const orderTotalAmount = order.get_total_with_tax(); + const tip_product_id = this.env.pos.config.tip_product_id && this.env.pos.config.tip_product_id[0]; + const tipLine = order + .get_orderlines() + .find((line) => tip_product_id && line.product.id === tip_product_id); + const tipAmount = tipLine ? tipLine.get_all_prices().priceWithTax : 0; + const orderAmountStr = this.env.pos.format_currency(orderTotalAmount - tipAmount); + if (!tipAmount) return orderAmountStr; + const tipAmountStr = this.env.pos.format_currency(tipAmount); + return `${orderAmountStr} + ${tipAmountStr} tip`; + } + get currentOrder() { + return this.env.pos.get_order(); + } + get nextScreen() { + return { name: 'ProductScreen' }; + } + whenClosing() { + this.orderDone(); + } + /** + * This function is called outside the rendering call stack. This way, + * we don't block the displaying of ReceiptScreen when it is mounted; additionally, + * any error that can happen during the printing does not affect the rendering. + */ + async handleAutoPrint() { + if (this._shouldAutoPrint()) { + await this.printReceipt(); + if (this.currentOrder._printed && this._shouldCloseImmediately()) { + this.whenClosing(); + } + } + } + orderDone() { + this.currentOrder.finalize(); + const { name, props } = this.nextScreen; + this.showScreen(name, props); + } + async printReceipt() { + const isPrinted = await this._printReceipt(); + if (isPrinted) { + this.currentOrder._printed = true; + } + } + _shouldAutoPrint() { + return this.env.pos.config.iface_print_auto && !this.currentOrder._printed; + } + _shouldCloseImmediately() { + var invoiced_finalized = this.currentOrder.is_to_invoice() ? this.currentOrder.finalized : true; + return this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized; + } + async _sendReceiptToCustomer() { + const printer = new Printer(); + const receiptString = this.orderReceipt.comp.el.outerHTML; + const ticketImage = await printer.htmlToImg(receiptString); + const order = this.currentOrder; + const client = order.get_client(); + const orderName = order.get_name(); + const orderClient = { email: this.orderUiState.inputEmail, name: client ? client.name : this.orderUiState.inputEmail }; + const order_server_id = this.env.pos.validated_orders_name_server_id_map[orderName]; + await this.rpc({ + model: 'pos.order', + method: 'action_receipt_to_customer', + args: [[order_server_id], orderName, orderClient, ticketImage], + }); + } + } + ReceiptScreen.template = 'ReceiptScreen'; + return ReceiptScreen; + }; + + Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen); + + return ReceiptScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js new file mode 100644 index 00000000..e7527eee --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js @@ -0,0 +1,18 @@ +odoo.define('point_of_sale.WrappedProductNameLines', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class WrappedProductNameLines extends PosComponent { + constructor() { + super(...arguments); + this.line = this.props.line; + } + } + WrappedProductNameLines.template = 'WrappedProductNameLines'; + + Registries.Component.add(WrappedProductNameLines); + + return WrappedProductNameLines; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js new file mode 100644 index 00000000..f9b1ea97 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js @@ -0,0 +1,102 @@ +odoo.define('point_of_sale.ScaleScreen', function(require) { + 'use strict'; + + const { useState, useExternalListener } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const { round_precision: round_pr } = require('web.utils'); + const Registries = require('point_of_sale.Registries'); + + class ScaleScreen extends PosComponent { + /** + * @param {Object} props + * @param {Object} props.product The product to weight. + */ + constructor() { + super(...arguments); + useExternalListener(document, 'keyup', this._onHotkeys); + this.state = useState({ weight: 0 }); + } + mounted() { + // start the scale reading + this._readScale(); + } + willUnmount() { + // stop the scale reading + this.env.pos.proxy_queue.clear(); + } + back() { + this.props.resolve({ confirmed: false, payload: null }); + this.trigger('close-temp-screen'); + } + confirm() { + this.props.resolve({ + confirmed: true, + payload: { weight: this.state.weight }, + }); + this.trigger('close-temp-screen'); + } + _onHotkeys(event) { + if (event.key === 'Escape') { + this.back(); + } else if (event.key === 'Enter') { + this.confirm(); + } + } + _readScale() { + this.env.pos.proxy_queue.schedule(this._setWeight.bind(this), { + duration: 500, + repeat: true, + }); + } + async _setWeight() { + const reading = await this.env.pos.proxy.scale_read(); + this.state.weight = reading.weight; + } + get _activePricelist() { + const current_order = this.env.pos.get_order(); + let current_pricelist = this.env.pos.default_pricelist; + if (current_order) { + current_pricelist = current_order.pricelist; + } + return current_pricelist; + } + get productWeightString() { + const defaultstr = (this.state.weight || 0).toFixed(3) + ' Kg'; + if (!this.props.product || !this.env.pos) { + return defaultstr; + } + const unit_id = this.props.product.uom_id; + if (!unit_id) { + return defaultstr; + } + const unit = this.env.pos.units_by_id[unit_id[0]]; + const weight = round_pr(this.state.weight || 0, unit.rounding); + let weightstr = weight.toFixed(Math.ceil(Math.log(1.0 / unit.rounding) / Math.log(10))); + weightstr += ' ' + unit.name; + return weightstr; + } + get computedPriceString() { + return this.env.pos.format_currency(this.productPrice * this.state.weight); + } + get productPrice() { + const product = this.props.product; + return (product ? product.get_price(this._activePricelist, this.state.weight) : 0) || 0; + } + get productName() { + return ( + (this.props.product ? this.props.product.display_name : undefined) || + 'Unnamed Product' + ); + } + get productUom() { + return this.props.product + ? this.env.pos.units_by_id[this.props.product.uom_id[0]].name + : ''; + } + } + ScaleScreen.template = 'ScaleScreen'; + + Registries.Component.add(ScaleScreen); + + return ScaleScreen; +}); diff --git a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js new file mode 100644 index 00000000..f59b72d0 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js @@ -0,0 +1,220 @@ +odoo.define('point_of_sale.TicketScreen', function (require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen'); + const { useListener } = require('web.custom_hooks'); + const { posbus } = require('point_of_sale.utils'); + + class TicketScreen extends IndependentToOrderScreen { + constructor() { + super(...arguments); + useListener('close-screen', this.close); + useListener('filter-selected', this._onFilterSelected); + useListener('search', this._onSearch); + this.searchDetails = {}; + this.filter = null; + this._initializeSearchFieldConstants(); + } + mounted() { + posbus.on('ticket-button-clicked', this, this.close); + this.env.pos.get('orders').on('add remove change', () => this.render(), this); + this.env.pos.on('change:selectedOrder', () => this.render(), this); + } + willUnmount() { + posbus.off('ticket-button-clicked', this); + this.env.pos.get('orders').off('add remove change', null, this); + this.env.pos.off('change:selectedOrder', null, this); + } + _onFilterSelected(event) { + this.filter = event.detail.filter; + this.render(); + } + _onSearch(event) { + const searchDetails = event.detail; + Object.assign(this.searchDetails, searchDetails); + this.render(); + } + /** + * Override to conditionally show the new ticket button. + */ + get showNewTicketButton() { + return true; + } + get orderList() { + return this.env.pos.get_order_list(); + } + get filteredOrderList() { + const { AllTickets } = this.getOrderStates(); + const filterCheck = (order) => { + if (this.filter && this.filter !== AllTickets) { + const screen = order.get_screen_data(); + return this.filter === this.constants.screenToStatusMap[screen.name]; + } + return true; + }; + const { fieldValue, searchTerm } = this.searchDetails; + const fieldAccessor = this._searchFields[fieldValue]; + const searchCheck = (order) => { + if (!fieldAccessor) return true; + const fieldValue = fieldAccessor(order); + if (fieldValue === null) return true; + if (!searchTerm) return true; + return fieldValue && fieldValue.toString().toLowerCase().includes(searchTerm.toLowerCase()); + }; + const predicate = (order) => { + return filterCheck(order) && searchCheck(order); + }; + return this.orderList.filter(predicate); + } + selectOrder(order) { + this._setOrder(order); + if (order === this.env.pos.get_order()) { + this.close(); + } + } + _setOrder(order) { + this.env.pos.set_order(order); + } + createNewOrder() { + this.env.pos.add_new_order(); + } + async deleteOrder(order) { + const screen = order.get_screen_data(); + if (['ProductScreen', 'PaymentScreen'].includes(screen.name) && order.get_orderlines().length > 0) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: 'Existing orderlines', + body: `${order.name} has total amount of ${this.getTotal( + order + )}, are you sure you want delete this order?`, + }); + if (!confirmed) return; + } + if (order) { + await this._canDeleteOrder(order); + order.destroy({ reason: 'abandon' }); + } + posbus.trigger('order-deleted'); + } + getDate(order) { + return moment(order.creation_date).format('YYYY-MM-DD hh:mm A'); + } + getTotal(order) { + return this.env.pos.format_currency(order.get_total_with_tax()); + } + getCustomer(order) { + return order.get_client_name(); + } + getCardholderName(order) { + return order.get_cardholder_name(); + } + getEmployee(order) { + return order.employee ? order.employee.name : ''; + } + getStatus(order) { + const screen = order.get_screen_data(); + return this.constants.screenToStatusMap[screen.name]; + } + /** + * Hide the delete button if one of the payments is a 'done' electronic payment. + */ + hideDeleteButton(order) { + return order + .get_paymentlines() + .some((payment) => payment.is_electronic() && payment.get_payment_status() === 'done'); + } + showCardholderName() { + return this.env.pos.payment_methods.some(method => method.use_payment_terminal); + } + get searchBarConfig() { + return { + searchFields: this.constants.searchFieldNames, + filter: { show: true, options: this.filterOptions }, + }; + } + get filterOptions() { + const { AllTickets, Ongoing, Payment, Receipt } = this.getOrderStates(); + return [AllTickets, Ongoing, Payment, Receipt]; + } + /** + * An object with keys containing the search field names which map to functions. + * The mapped functions will be used to generate representative string for the order + * to match the search term when searching. + * E.g. Given 2 orders, search those with `Receipt Number` containing `1111`. + * ``` + * orders = [{ + * name: '000-1111-222' + * total: 10, + * }, { + * name: '444-5555-666' + * total: 15, + * }] + * ``` + * `Receipt Number` search field maps to the `name` of the order. So, the orders will be + * represented by their name, and the search will result to: + * ``` + * result = [{ + * name: '000-1111-222', + * total: 10, + * }] + * ``` + * @returns Record<string, (models.Order) => string> + */ + get _searchFields() { + const { ReceiptNumber, Date, Customer, CardholderName } = this.getSearchFieldNames(); + var fields = { + [ReceiptNumber]: (order) => order.name, + [Date]: (order) => moment(order.creation_date).format('YYYY-MM-DD hh:mm A'), + [Customer]: (order) => order.get_client_name(), + }; + + if (this.showCardholderName()) { + fields[CardholderName] = (order) => order.get_cardholder_name(); + } + + return fields; + } + /** + * Maps the order screen params to order status. + */ + get _screenToStatusMap() { + const { Ongoing, Payment, Receipt } = this.getOrderStates(); + return { + ProductScreen: Ongoing, + PaymentScreen: Payment, + ReceiptScreen: Receipt, + }; + } + _initializeSearchFieldConstants() { + this.constants = {}; + Object.assign(this.constants, { + searchFieldNames: Object.keys(this._searchFields), + screenToStatusMap: this._screenToStatusMap, + }); + } + async _canDeleteOrder(order) { + return true; + } + getOrderStates() { + return { + AllTickets: this.env._t('All Tickets'), + Ongoing: this.env._t('Ongoing'), + Payment: this.env._t('Payment'), + Receipt: this.env._t('Receipt'), + }; + } + getSearchFieldNames() { + return { + ReceiptNumber: this.env._t('Receipt Number'), + Date: this.env._t('Date'), + Customer: this.env._t('Customer'), + CardholderName: this.env._t('Cardholder Name'), + }; + } + } + TicketScreen.template = 'TicketScreen'; + + Registries.Component.add(TicketScreen); + + return TicketScreen; +}); diff --git a/addons/point_of_sale/static/src/js/barcode_reader.js b/addons/point_of_sale/static/src/js/barcode_reader.js new file mode 100644 index 00000000..10f5b9a2 --- /dev/null +++ b/addons/point_of_sale/static/src/js/barcode_reader.js @@ -0,0 +1,158 @@ +odoo.define('point_of_sale.BarcodeReader', function (require) { +"use strict"; + +var core = require('web.core'); + +// this module interfaces with the barcode reader. It assumes the barcode reader +// is set-up to act like a keyboard. Use connect() and disconnect() to activate +// and deactivate the barcode reader. Use set_action_callbacks to tell it +// what to do when it reads a barcode. +var BarcodeReader = core.Class.extend({ + actions:[ + 'product', + 'cashier', + 'client', + ], + + init: function (attributes) { + this.pos = attributes.pos; + this.action_callbacks = {}; + this.exclusive_callbacks = {}; + this.proxy = attributes.proxy; + this.remote_scanning = false; + this.remote_active = 0; + + this.barcode_parser = attributes.barcode_parser; + + this.action_callback_stack = []; + + core.bus.on('barcode_scanned', this, function (barcode) { + this.scan(barcode); + }); + }, + + set_barcode_parser: function (barcode_parser) { + this.barcode_parser = barcode_parser; + }, + + // when a barcode is scanned and parsed, the callback corresponding + // to its type is called with the parsed_barcode as a parameter. + // (parsed_barcode is the result of parse_barcode(barcode)) + // + // callbacks is a Map of 'actions' : callback(parsed_barcode) + // that sets the callback for each action. if a callback for the + // specified action already exists, it is replaced. + // + // possible actions include : + // 'product' | 'cashier' | 'client' | 'discount' + set_action_callback: function (name, callback) { + if (this.action_callbacks[name]) { + this.action_callbacks[name].add(callback); + } else { + this.action_callbacks[name] = new Set([callback]); + } + }, + + remove_action_callback: function(name, callback) { + if (!callback) { + delete this.action_callbacks[name]; + return; + } + const callbacks = this.action_callbacks[name]; + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + delete this.action_callbacks[name]; + } + } + }, + + /** + * Allow setting of exclusive callbacks. If there are exclusive callbacks, + * these callbacks are called neglecting the regular callbacks. This is + * useful for rendered Components that wants to take exclusive access + * to the barcode reader. + * + * @param {String} name + * @param {Function} callback function that takes parsed barcode + */ + set_exclusive_callback: function (name, callback) { + if (this.exclusive_callbacks[name]) { + this.exclusive_callbacks[name].add(callback); + } else { + this.exclusive_callbacks[name] = new Set([callback]); + } + }, + + remove_exclusive_callback: function (name, callback) { + if (!callback) { + delete this.exclusive_callbacks[name]; + return; + } + const callbacks = this.exclusive_callbacks[name]; + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + delete this.exclusive_callbacks[name]; + } + } + }, + + scan: function (code) { + if (!code) return; + + const callbacks = Object.keys(this.exclusive_callbacks).length + ? this.exclusive_callbacks + : this.action_callbacks; + + const parsed_result = this.barcode_parser.parse_barcode(code); + if (callbacks[parsed_result.type]) { + [...callbacks[parsed_result.type]].map((cb) => cb(parsed_result)); + } else if (callbacks.error) { + [...callbacks.error].map((cb) => cb(parsed_result)); + } else { + console.warn('Ignored Barcode Scan:', parsed_result); + } + + }, + + // the barcode scanner will listen on the hw_proxy/scanner interface for + // scan events until disconnect_from_proxy is called + connect_to_proxy: function () { + var self = this; + this.remote_scanning = true; + if (this.remote_active >= 1) { + return; + } + this.remote_active = 1; + + function waitforbarcode(){ + return self.proxy.connection.rpc('/hw_proxy/scanner',{},{shadow: true, timeout:7500}) + .then(function (barcode) { + if (!self.remote_scanning) { + self.remote_active = 0; + return; + } + self.scan(barcode); + waitforbarcode(); + }, + function () { + if (!self.remote_scanning) { + self.remote_active = 0; + return; + } + waitforbarcode(); + }); + } + waitforbarcode(); + }, + + // the barcode scanner will stop listening on the hw_proxy/scanner remote interface + disconnect_from_proxy: function () { + this.remote_scanning = false; + }, +}); + +return BarcodeReader; + +}); diff --git a/addons/point_of_sale/static/src/js/custom_hooks.js b/addons/point_of_sale/static/src/js/custom_hooks.js new file mode 100644 index 00000000..c1e87b24 --- /dev/null +++ b/addons/point_of_sale/static/src/js/custom_hooks.js @@ -0,0 +1,149 @@ +odoo.define('point_of_sale.custom_hooks', function (require) { + 'use strict'; + + const { Component } = owl; + const { onMounted, onPatched, onWillUnmount } = owl.hooks; + + /** + * Introduce error handlers in the component. + * + * IMPROVEMENT: This is a terrible hook. There could be a better way to handle + * the error when the order failed to sync. + */ + function useErrorHandlers() { + const component = Component.current; + + component._handlePushOrderError = async function (error) { + // This error handler receives `error` equivalent to `error.message` of the rpc error. + if (error.message === 'Backend Invoice') { + await this.showPopup('ConfirmPopup', { + title: this.env._t('Please print the invoice from the backend'), + body: + this.env._t( + 'The order has been synchronized earlier. Please make the invoice from the backend for the order: ' + ) + error.data.order.name, + }); + } else if (error.code < 0) { + // XmlHttpRequest Errors + const title = this.env._t('Unable to sync order'); + const body = this.env._t( + 'Check the internet connection then try to sync again by clicking on the red wifi button (upper right of the screen).' + ); + await this.showPopup('OfflineErrorPopup', { title, body }); + } else if (error.code === 200) { + // OpenERP Server Errors + await this.showPopup('ErrorTracebackPopup', { + title: error.data.message || this.env._t('Server Error'), + body: + error.data.debug || + this.env._t('The server encountered an error while receiving your order.'), + }); + } else if (error.code === 700) { + // Fiscal module errors + await this.showPopup('ErrorPopup', { + title: this.env._t('Fiscal data module error'), + body: + error.data.error.status || + this.env._t('The fiscal data module encountered an error while receiving your order.'), + }); + } else { + // ??? + await this.showPopup('ErrorPopup', { + title: this.env._t('Unknown Error'), + body: this.env._t( + 'The order could not be sent to the server due to an unknown error' + ), + }); + } + }; + } + + function useAutoFocusToLast() { + const current = Component.current; + let target = null; + function autofocus() { + const prevTarget = target; + const allInputs = current.el.querySelectorAll('input'); + target = allInputs[allInputs.length - 1]; + if (target && target !== prevTarget) { + target.focus(); + target.selectionStart = target.selectionEnd = target.value.length; + } + } + onMounted(autofocus); + onPatched(autofocus); + } + + /** + * Use this hook when you want to do something on previously selected and + * newly selected order when the order changes. + * + * Normally, a component is rendered then the current order is changed. When + * this happens, we want to rerender the component because the new information + * should be reflected in the screen. Additionally, we might want to remove listeners + * to the previous order and attach listeners to the new one. This hook is + * perfect for the described situation. + * + * Internally, this hook performs the following: + * 1. call newOrderCB on mounted + * 2. listen to order changes and perform the following sequence: + * - call prevOrderCB(prevOrder) + * - call newOrderCB(newOrder) + * 3. call prevOrderCB on willUnmount + * + * @param {Function} prevOrderCB apply this callback on the previous order + * @param {Function} newOrderCB apply this callback on the new order + */ + function onChangeOrder(prevOrderCB, newOrderCB) { + const current = Component.current; + prevOrderCB = prevOrderCB ? prevOrderCB.bind(current) : () => {}; + newOrderCB = newOrderCB ? newOrderCB.bind(current) : () => {}; + onMounted(() => { + current.env.pos.on( + 'change:selectedOrder', + async (pos, newOrder) => { + await prevOrderCB(pos.previous('selectedOrder')); + await newOrderCB(newOrder); + }, + current + ); + newOrderCB(current.env.pos.get_order()); + }); + onWillUnmount(() => { + current.env.pos.off('change:selectedOrder', null, current); + prevOrderCB(current.env.pos.get_order()); + }); + } + + function useBarcodeReader(callbackMap, exclusive = false) { + const current = Component.current; + const barcodeReader = current.env.pos.barcode_reader; + for (let [key, callback] of Object.entries(callbackMap)) { + callbackMap[key] = callback.bind(current); + } + onMounted(() => { + if (barcodeReader) { + for (let key in callbackMap) { + if (exclusive) { + barcodeReader.set_exclusive_callback(key, callbackMap[key]); + } else { + barcodeReader.set_action_callback(key, callbackMap[key]); + } + } + } + }); + onWillUnmount(() => { + if (barcodeReader) { + for (let key in callbackMap) { + if (exclusive) { + barcodeReader.remove_exclusive_callback(key, callbackMap[key]); + } else { + barcodeReader.remove_action_callback(key, callbackMap[key]); + } + } + } + }); + } + + return { useErrorHandlers, useAutoFocusToLast, onChangeOrder, useBarcodeReader }; +}); diff --git a/addons/point_of_sale/static/src/js/db.js b/addons/point_of_sale/static/src/js/db.js new file mode 100644 index 00000000..ca0afd28 --- /dev/null +++ b/addons/point_of_sale/static/src/js/db.js @@ -0,0 +1,556 @@ +odoo.define('point_of_sale.DB', function (require) { +"use strict"; + +var core = require('web.core'); +var utils = require('web.utils'); +/* The PosDB holds reference to data that is either + * - static: does not change between pos reloads + * - persistent : must stay between reloads ( orders ) + */ + +var PosDB = core.Class.extend({ + name: 'openerp_pos_db', //the prefix of the localstorage data + limit: 100, // the maximum number of results returned by a search + init: function(options){ + options = options || {}; + this.name = options.name || this.name; + this.limit = options.limit || this.limit; + + if (options.uuid) { + this.name = this.name + '_' + options.uuid; + } + + //cache the data in memory to avoid roundtrips to the localstorage + this.cache = {}; + + this.product_by_id = {}; + this.product_by_barcode = {}; + this.product_by_category_id = {}; + + this.partner_sorted = []; + this.partner_by_id = {}; + this.partner_by_barcode = {}; + this.partner_search_string = ""; + this.partner_write_date = null; + + this.category_by_id = {}; + this.root_category_id = 0; + this.category_products = {}; + this.category_ancestors = {}; + this.category_childs = {}; + this.category_parent = {}; + this.category_search_string = {}; + }, + + /** + * sets an uuid to prevent conflict in locally stored data between multiple PoS Configs. By + * using the uuid of the config the local storage from other configs will not get effected nor + * loaded in sessions that don't belong to them. + * + * @param {string} uuid Unique identifier of the PoS Config linked to the current session. + */ + set_uuid: function(uuid){ + this.name = this.name + '_' + uuid; + }, + + /* returns the category object from its id. If you pass a list of id as parameters, you get + * a list of category objects. + */ + get_category_by_id: function(categ_id){ + if(categ_id instanceof Array){ + var list = []; + for(var i = 0, len = categ_id.length; i < len; i++){ + var cat = this.category_by_id[categ_id[i]]; + if(cat){ + list.push(cat); + }else{ + console.error("get_category_by_id: no category has id:",categ_id[i]); + } + } + return list; + }else{ + return this.category_by_id[categ_id]; + } + }, + /* returns a list of the category's child categories ids, or an empty list + * if a category has no childs */ + get_category_childs_ids: function(categ_id){ + return this.category_childs[categ_id] || []; + }, + /* returns a list of all ancestors (parent, grand-parent, etc) categories ids + * starting from the root category to the direct parent */ + get_category_ancestors_ids: function(categ_id){ + return this.category_ancestors[categ_id] || []; + }, + /* returns the parent category's id of a category, or the root_category_id if no parent. + * the root category is parent of itself. */ + get_category_parent_id: function(categ_id){ + return this.category_parent[categ_id] || this.root_category_id; + }, + /* adds categories definitions to the database. categories is a list of categories objects as + * returned by the openerp server. Categories must be inserted before the products or the + * product/ categories association may (will) not work properly */ + add_categories: function(categories){ + var self = this; + if(!this.category_by_id[this.root_category_id]){ + this.category_by_id[this.root_category_id] = { + id : this.root_category_id, + name : 'Root', + }; + } + categories.forEach(function(cat){ + self.category_by_id[cat.id] = cat; + }); + categories.forEach(function(cat){ + var parent_id = cat.parent_id[0]; + if(!(parent_id && self.category_by_id[parent_id])){ + parent_id = self.root_category_id; + } + self.category_parent[cat.id] = parent_id; + if(!self.category_childs[parent_id]){ + self.category_childs[parent_id] = []; + } + self.category_childs[parent_id].push(cat.id); + }); + function make_ancestors(cat_id, ancestors){ + self.category_ancestors[cat_id] = ancestors; + + ancestors = ancestors.slice(0); + ancestors.push(cat_id); + + var childs = self.category_childs[cat_id] || []; + for(var i=0, len = childs.length; i < len; i++){ + make_ancestors(childs[i], ancestors); + } + } + make_ancestors(this.root_category_id, []); + }, + category_contains: function(categ_id, product_id) { + var product = this.product_by_id[product_id]; + if (product) { + var cid = product.pos_categ_id[0]; + while (cid && cid !== categ_id){ + cid = this.category_parent[cid]; + } + return !!cid; + } + return false; + }, + /* loads a record store from the database. returns default if nothing is found */ + load: function(store,deft){ + if(this.cache[store] !== undefined){ + return this.cache[store]; + } + var data = localStorage[this.name + '_' + store]; + if(data !== undefined && data !== ""){ + data = JSON.parse(data); + this.cache[store] = data; + return data; + }else{ + return deft; + } + }, + /* saves a record store to the database */ + save: function(store,data){ + localStorage[this.name + '_' + store] = JSON.stringify(data); + this.cache[store] = data; + }, + _product_search_string: function(product){ + var str = product.display_name; + if (product.barcode) { + str += '|' + product.barcode; + } + if (product.default_code) { + str += '|' + product.default_code; + } + if (product.description) { + str += '|' + product.description; + } + if (product.description_sale) { + str += '|' + product.description_sale; + } + str = product.id + ':' + str.replace(/:/g,'') + '\n'; + return str; + }, + add_products: function(products){ + var stored_categories = this.product_by_category_id; + + if(!products instanceof Array){ + products = [products]; + } + for(var i = 0, len = products.length; i < len; i++){ + var product = products[i]; + if (product.id in this.product_by_id) continue; + if (product.available_in_pos){ + var search_string = utils.unaccent(this._product_search_string(product)); + var categ_id = product.pos_categ_id ? product.pos_categ_id[0] : this.root_category_id; + product.product_tmpl_id = product.product_tmpl_id[0]; + if(!stored_categories[categ_id]){ + stored_categories[categ_id] = []; + } + stored_categories[categ_id].push(product.id); + + if(this.category_search_string[categ_id] === undefined){ + this.category_search_string[categ_id] = ''; + } + this.category_search_string[categ_id] += search_string; + + var ancestors = this.get_category_ancestors_ids(categ_id) || []; + + for(var j = 0, jlen = ancestors.length; j < jlen; j++){ + var ancestor = ancestors[j]; + if(! stored_categories[ancestor]){ + stored_categories[ancestor] = []; + } + stored_categories[ancestor].push(product.id); + + if( this.category_search_string[ancestor] === undefined){ + this.category_search_string[ancestor] = ''; + } + this.category_search_string[ancestor] += search_string; + } + } + this.product_by_id[product.id] = product; + if(product.barcode){ + this.product_by_barcode[product.barcode] = product; + } + } + }, + _partner_search_string: function(partner){ + var str = partner.name || ''; + if(partner.barcode){ + str += '|' + partner.barcode; + } + if(partner.address){ + str += '|' + partner.address; + } + if(partner.phone){ + str += '|' + partner.phone.split(' ').join(''); + } + if(partner.mobile){ + str += '|' + partner.mobile.split(' ').join(''); + } + if(partner.email){ + str += '|' + partner.email; + } + if(partner.vat){ + str += '|' + partner.vat; + } + str = '' + partner.id + ':' + str.replace(':', '').replace(/\n/g, ' ') + '\n'; + return str; + }, + add_partners: function(partners){ + var updated_count = 0; + var new_write_date = ''; + var partner; + for(var i = 0, len = partners.length; i < len; i++){ + partner = partners[i]; + + var local_partner_date = (this.partner_write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z'); + var dist_partner_date = (partner.write_date || '').replace(/^(\d{4}-\d{2}-\d{2}) ((\d{2}:?){3})$/, '$1T$2Z'); + if ( this.partner_write_date && + this.partner_by_id[partner.id] && + new Date(local_partner_date).getTime() + 1000 >= + new Date(dist_partner_date).getTime() ) { + // FIXME: The write_date is stored with milisec precision in the database + // but the dates we get back are only precise to the second. This means when + // you read partners modified strictly after time X, you get back partners that were + // modified X - 1 sec ago. + continue; + } else if ( new_write_date < partner.write_date ) { + new_write_date = partner.write_date; + } + if (!this.partner_by_id[partner.id]) { + this.partner_sorted.push(partner.id); + } + this.partner_by_id[partner.id] = partner; + + updated_count += 1; + } + + this.partner_write_date = new_write_date || this.partner_write_date; + + if (updated_count) { + // If there were updates, we need to completely + // rebuild the search string and the barcode indexing + + this.partner_search_string = ""; + this.partner_by_barcode = {}; + + for (var id in this.partner_by_id) { + partner = this.partner_by_id[id]; + + if(partner.barcode){ + this.partner_by_barcode[partner.barcode] = partner; + } + partner.address = (partner.street ? partner.street + ', ': '') + + (partner.zip ? partner.zip + ', ': '') + + (partner.city ? partner.city + ', ': '') + + (partner.state_id ? partner.state_id[1] + ', ': '') + + (partner.country_id ? partner.country_id[1]: ''); + this.partner_search_string += this._partner_search_string(partner); + } + + this.partner_search_string = utils.unaccent(this.partner_search_string); + } + return updated_count; + }, + get_partner_write_date: function(){ + return this.partner_write_date || "1970-01-01 00:00:00"; + }, + get_partner_by_id: function(id){ + return this.partner_by_id[id]; + }, + get_partner_by_barcode: function(barcode){ + return this.partner_by_barcode[barcode]; + }, + get_partners_sorted: function(max_count){ + max_count = max_count ? Math.min(this.partner_sorted.length, max_count) : this.partner_sorted.length; + var partners = []; + for (var i = 0; i < max_count; i++) { + partners.push(this.partner_by_id[this.partner_sorted[i]]); + } + return partners; + }, + search_partner: function(query){ + try { + query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.'); + query = query.replace(/ /g,'.+'); + var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi"); + }catch(e){ + return []; + } + var results = []; + for(var i = 0; i < this.limit; i++){ + var r = re.exec(this.partner_search_string); + if(r){ + var id = Number(r[1]); + results.push(this.get_partner_by_id(id)); + }else{ + break; + } + } + return results; + }, + /* removes all the data from the database. TODO : being able to selectively remove data */ + clear: function(){ + for(var i = 0, len = arguments.length; i < len; i++){ + localStorage.removeItem(this.name + '_' + arguments[i]); + } + }, + /* this internal methods returns the count of properties in an object. */ + _count_props : function(obj){ + var count = 0; + for(var prop in obj){ + if(obj.hasOwnProperty(prop)){ + count++; + } + } + return count; + }, + get_product_by_id: function(id){ + return this.product_by_id[id]; + }, + get_product_by_barcode: function(barcode){ + if(this.product_by_barcode[barcode]){ + return this.product_by_barcode[barcode]; + } else { + return undefined; + } + }, + get_product_by_category: function(category_id){ + var product_ids = this.product_by_category_id[category_id]; + var list = []; + if (product_ids) { + for (var i = 0, len = Math.min(product_ids.length, this.limit); i < len; i++) { + list.push(this.product_by_id[product_ids[i]]); + } + } + return list; + }, + /* returns a list of products with : + * - a category that is or is a child of category_id, + * - a name, package or barcode containing the query (case insensitive) + */ + search_product_in_category: function(category_id, query){ + try { + query = query.replace(/[\[\]\(\)\+\*\?\.\-\!\&\^\$\|\~\_\{\}\:\,\\\/]/g,'.'); + query = query.replace(/ /g,'.+'); + var re = RegExp("([0-9]+):.*?"+utils.unaccent(query),"gi"); + }catch(e){ + return []; + } + var results = []; + for(var i = 0; i < this.limit; i++){ + var r = re.exec(this.category_search_string[category_id]); + if(r){ + var id = Number(r[1]); + results.push(this.get_product_by_id(id)); + }else{ + break; + } + } + return results; + }, + /* from a product id, and a list of category ids, returns + * true if the product belongs to one of the provided category + * or one of its child categories. + */ + is_product_in_category: function(category_ids, product_id) { + if (!(category_ids instanceof Array)) { + category_ids = [category_ids]; + } + var cat = this.get_product_by_id(product_id).pos_categ_id[0]; + while (cat) { + for (var i = 0; i < category_ids.length; i++) { + if (cat == category_ids[i]) { // The == is important, ids may be strings + return true; + } + } + cat = this.get_category_parent_id(cat); + } + return false; + }, + + /* paid orders */ + add_order: function(order){ + var order_id = order.uid; + var orders = this.load('orders',[]); + + // if the order was already stored, we overwrite its data + for(var i = 0, len = orders.length; i < len; i++){ + if(orders[i].id === order_id){ + orders[i].data = order; + this.save('orders',orders); + return order_id; + } + } + + // Only necessary when we store a new, validated order. Orders + // that where already stored should already have been removed. + this.remove_unpaid_order(order); + + orders.push({id: order_id, data: order}); + this.save('orders',orders); + return order_id; + }, + remove_order: function(order_id){ + var orders = this.load('orders',[]); + orders = _.filter(orders, function(order){ + return order.id !== order_id; + }); + this.save('orders',orders); + }, + remove_all_orders: function(){ + this.save('orders',[]); + }, + get_orders: function(){ + return this.load('orders',[]); + }, + get_order: function(order_id){ + var orders = this.get_orders(); + for(var i = 0, len = orders.length; i < len; i++){ + if(orders[i].id === order_id){ + return orders[i]; + } + } + return undefined; + }, + + /* working orders */ + save_unpaid_order: function(order){ + var order_id = order.uid; + var orders = this.load('unpaid_orders',[]); + var serialized = order.export_as_JSON(); + + for (var i = 0; i < orders.length; i++) { + if (orders[i].id === order_id){ + orders[i].data = serialized; + this.save('unpaid_orders',orders); + return order_id; + } + } + + orders.push({id: order_id, data: serialized}); + this.save('unpaid_orders',orders); + return order_id; + }, + remove_unpaid_order: function(order){ + var orders = this.load('unpaid_orders',[]); + orders = _.filter(orders, function(o){ + return o.id !== order.uid; + }); + this.save('unpaid_orders',orders); + }, + remove_all_unpaid_orders: function(){ + this.save('unpaid_orders',[]); + }, + get_unpaid_orders: function(){ + var saved = this.load('unpaid_orders',[]); + var orders = []; + for (var i = 0; i < saved.length; i++) { + orders.push(saved[i].data); + } + return orders; + }, + /** + * Return the orders with requested ids if they are unpaid. + * @param {array<number>} ids order_ids. + * @return {array<object>} list of orders. + */ + get_unpaid_orders_to_sync: function(ids){ + var saved = this.load('unpaid_orders',[]); + var orders = []; + saved.forEach(function(o) { + if (ids.includes(o.id) && (o.data.server_id || o.data.lines.length)){ + orders.push(o); + } + }); + return orders; + }, + /** + * Add a given order to the orders to be removed from the server. + * + * If an order is removed from a table it also has to be removed from the server to prevent it from reapearing + * after syncing. This function will add the server_id of the order to a list of orders still to be removed. + * @param {object} order object. + */ + set_order_to_remove_from_server: function(order){ + if (order.server_id !== undefined) { + var to_remove = this.load('unpaid_orders_to_remove',[]); + to_remove.push(order.server_id); + this.save('unpaid_orders_to_remove', to_remove); + } + }, + /** + * Get a list of server_ids of orders to be removed. + * @return {array<number>} list of server_ids. + */ + get_ids_to_remove_from_server: function(){ + return this.load('unpaid_orders_to_remove',[]); + }, + /** + * Remove server_ids from the list of orders to be removed. + * @param {array<number>} ids + */ + set_ids_removed_from_server: function(ids){ + var to_remove = this.load('unpaid_orders_to_remove',[]); + + to_remove = _.filter(to_remove, function(id){ + return !ids.includes(id); + }); + this.save('unpaid_orders_to_remove', to_remove); + }, + set_cashier: function(cashier) { + // Always update if the user is the same as before + this.save('cashier', cashier || null); + }, + get_cashier: function() { + return this.load('cashier'); + } +}); + +return PosDB; + +}); + diff --git a/addons/point_of_sale/static/src/js/debug_manager.js b/addons/point_of_sale/static/src/js/debug_manager.js new file mode 100644 index 00000000..bcc9f608 --- /dev/null +++ b/addons/point_of_sale/static/src/js/debug_manager.js @@ -0,0 +1,20 @@ +odoo.define('point_of_sale.DebugManager.Backend', function(require) { + 'use strict'; + + const { _t } = require('web.core'); + const DebugManager = require('web.DebugManager.Backend'); + + DebugManager.include({ + /** + * Runs the JS (desktop) tests + */ + perform_pos_js_tests() { + this.do_action({ + name: _t('JS Tests'), + target: 'new', + type: 'ir.actions.act_url', + url: '/pos/ui/tests?mod=*', + }); + }, + }); +}); diff --git a/addons/point_of_sale/static/src/js/devices.js b/addons/point_of_sale/static/src/js/devices.js new file mode 100644 index 00000000..a4a80a9c --- /dev/null +++ b/addons/point_of_sale/static/src/js/devices.js @@ -0,0 +1,492 @@ +odoo.define('point_of_sale.devices', function (require) { +"use strict"; + +var core = require('web.core'); +var mixins = require('web.mixins'); +var Session = require('web.Session'); +var Printer = require('point_of_sale.Printer').Printer; + +// the JobQueue schedules a sequence of 'jobs'. each job is +// a function returning a promise. The queue waits for each job to finish +// before launching the next. Each job can also be scheduled with a delay. +// the is used to prevent parallel requests to the proxy. + +var JobQueue = function(){ + var queue = []; + var running = false; + var scheduled_end_time = 0; + var end_of_queue = Promise.resolve(); + var stoprepeat = false; + + var run = function () { + var runNextJob = function () { + if (queue.length === 0) { + running = false; + scheduled_end_time = 0; + return Promise.resolve(); + } + running = true; + var job = queue[0]; + if (!job.opts.repeat || stoprepeat) { + queue.shift(); + stoprepeat = false; + } + + // the time scheduled for this job + scheduled_end_time = (new Date()).getTime() + (job.opts.duration || 0); + + // we run the job and put in prom when it finishes + var prom = job.fun() || Promise.resolve(); + + var always = function () { + // we run the next job after the scheduled_end_time, even if it finishes before + return new Promise(function (resolve, reject) { + setTimeout( + resolve, + Math.max(0, scheduled_end_time - (new Date()).getTime()) + ); + }); + }; + // we don't care if a job fails ... + return prom.then(always, always).then(runNextJob); + }; + + if (!running) { + end_of_queue = runNextJob(); + } + }; + + /** + * Adds a job to the schedule. + * + * @param {function} fun must return a promise + * @param {object} [opts] + * @param {number} [opts.duration] the job is guaranteed to finish no quicker than this (milisec) + * @param {boolean} [opts.repeat] if true, the job will be endlessly repeated + * @param {boolean} [opts.important] if true, the scheduled job cannot be canceled by a queue.clear() + */ + this.schedule = function (fun, opts) { + queue.push({fun:fun, opts:opts || {}}); + if(!running){ + run(); + } + }; + + // remove all jobs from the schedule (except the ones marked as important) + this.clear = function(){ + queue = _.filter(queue,function(job){return job.opts.important === true;}); + }; + + // end the repetition of the current job + this.stoprepeat = function(){ + stoprepeat = true; + }; + + /** + * Returns a promise that resolves when all scheduled jobs have been run. + * (jobs added after the call to this method are considered as well) + * + * @returns {Promise} + */ + this.finished = function () { + return end_of_queue; + }; + +}; + + +// this object interfaces with the local proxy to communicate to the various hardware devices +// connected to the Point of Sale. As the communication only goes from the POS to the proxy, +// methods are used both to signal an event, and to fetch information. + +var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{ + init: function(parent,options){ + mixins.PropertiesMixin.init.call(this); + var self = this; + this.setParent(parent); + options = options || {}; + + this.pos = parent; + + this.weighing = false; + this.debug_weight = 0; + this.use_debug_weight = false; + + this.paying = false; + this.default_payment_status = { + status: 'waiting', + message: '', + payment_method: undefined, + receipt_client: undefined, + receipt_shop: undefined, + }; + this.custom_payment_status = this.default_payment_status; + + this.notifications = {}; + this.bypass_proxy = false; + + this.connection = null; + this.host = ''; + this.keptalive = false; + + this.set('status',{}); + + this.set_connection_status('disconnected'); + + this.on('change:status',this,function(eh,status){ + status = status.newValue; + if(status.status === 'connected' && self.printer) { + self.printer.print_receipt(); + } + }); + + this.posbox_supports_display = true; + + window.hw_proxy = this; + }, + set_connection_status: function(status, drivers, msg=''){ + var oldstatus = this.get('status'); + var newstatus = {}; + newstatus.status = status; + newstatus.drivers = status === 'disconnected' ? {} : oldstatus.drivers; + newstatus.drivers = drivers ? drivers : newstatus.drivers; + newstatus.msg = msg; + this.set('status',newstatus); + }, + disconnect: function(){ + if(this.get('status').status !== 'disconnected'){ + this.connection.destroy(); + this.set_connection_status('disconnected'); + } + }, + + /** + * Connects to the specified url. + * + * @param {string} url + * @returns {Promise} + */ + connect: function(url){ + var self = this; + this.connection = new Session(undefined,url, { use_cors: true}); + this.host = url; + if (this.pos.config.iface_print_via_proxy) { + this.connect_to_printer(); + } + this.set_connection_status('connecting',{}); + + return this.message('handshake').then(function(response){ + if(response){ + self.set_connection_status('connected'); + localStorage.hw_proxy_url = url; + self.keepalive(); + }else{ + self.set_connection_status('disconnected'); + console.error('Connection refused by the Proxy'); + } + },function(){ + self.set_connection_status('disconnected'); + console.error('Could not connect to the Proxy'); + }); + }, + + connect_to_printer: function () { + this.printer = new Printer(this.host, this.pos); + }, + + /** + * Find a proxy and connects to it. + * + * @param {Object} [options] + * @param {string} [options.force_ip] only try to connect to the specified ip. + * @param {string} [options.port] @see find_proxy + * @param {function} [options.progress] @see find_proxy + * @returns {Promise} + */ + autoconnect: function (options) { + var self = this; + this.set_connection_status('connecting',{}); + if (this.pos.config.iface_print_via_proxy) { + this.connect_to_printer(); + } + var found_url = new Promise(function () {}); + + if (options.force_ip) { + // if the ip is forced by server config, bailout on fail + found_url = this.try_hard_to_connect(options.force_ip, options); + } else if (localStorage.hw_proxy_url) { + // try harder when we remember a good proxy url + found_url = this.try_hard_to_connect(localStorage.hw_proxy_url, options) + .catch(function () { + if (window.location.protocol != 'https:') { + return self.find_proxy(options); + } + }); + } else { + // just find something quick + if (window.location.protocol != 'https:'){ + found_url = this.find_proxy(options); + } + } + + var successProm = found_url.then(function (url) { + return self.connect(url); + }); + + successProm.catch(function () { + self.set_connection_status('disconnected'); + }); + + return successProm; + }, + + // starts a loop that updates the connection status + keepalive: function () { + var self = this; + + function status(){ + var always = function () { + setTimeout(status, 5000); + }; + self.connection.rpc('/hw_proxy/status_json',{},{shadow: true, timeout:2500}) + .then(function (driver_status) { + self.set_connection_status('connected',driver_status); + }, function () { + if(self.get('status').status !== 'connecting'){ + self.set_connection_status('disconnected'); + } + }).then(always, always); + } + + if (!this.keptalive) { + this.keptalive = true; + status(); + } + }, + + /** + * @param {string} name + * @param {Object} [params] + * @returns {Promise} + */ + message : function (name, params) { + var callbacks = this.notifications[name] || []; + for (var i = 0; i < callbacks.length; i++) { + callbacks[i](params); + } + if (this.get('status').status !== 'disconnected') { + return this.connection.rpc('/hw_proxy/' + name, params || {}, {shadow: true}); + } else { + return Promise.reject(); + } + }, + + /** + * Tries several time to connect to a known proxy url. + * + * @param {*} url + * @param {Object} [options] + * @param {string} [options.port=8069] what port to listen to + * @returns {Promise<string|Array>} + */ + try_hard_to_connect: function (url, options) { + options = options || {}; + var protocol = window.location.protocol; + var port = ( !options.port && protocol == "https:") ? ':443' : ':' + (options.port || '8069'); + + this.set_connection_status('connecting'); + + if(url.indexOf('//') < 0){ + url = protocol + '//' + url; + } + + if(url.indexOf(':',5) < 0){ + url = url + port; + } + + // try real hard to connect to url, with a 1sec timeout and up to 'retries' retries + function try_real_hard_to_connect(url, retries) { + return Promise.resolve( + $.ajax({ + url: url + '/hw_proxy/hello', + method: 'GET', + timeout: 1000, + }) + .then(function () { + return Promise.resolve(url); + }, function (resp) { + if (retries > 0) { + return try_real_hard_to_connect(url, retries-1); + } else { + return Promise.reject([resp.statusText, url]); + } + }) + ); + } + + return try_real_hard_to_connect(url, 3); + }, + + /** + * Returns as a promise a valid host url that can be used as proxy. + * + * @param {Object} [options] + * @param {string} [options.port] what port to listen to (default 8069) + * @param {function} [options.progress] callback for search progress ( fac in [0,1] ) + * @returns {Promise<string>} will be resolved with the proxy valid url + */ + find_proxy: function(options){ + options = options || {}; + var self = this; + var port = ':' + (options.port || '8069'); + var urls = []; + var found = false; + var parallel = 8; + var threads = []; + var progress = 0; + + + urls.push('http://localhost'+port); + for(var i = 0; i < 256; i++){ + urls.push('http://192.168.0.'+i+port); + urls.push('http://192.168.1.'+i+port); + urls.push('http://10.0.0.'+i+port); + } + + var prog_inc = 1/urls.length; + + function update_progress(){ + progress = found ? 1 : progress + prog_inc; + if(options.progress){ + options.progress(progress); + } + } + + function thread () { + var url = urls.shift(); + + if (!url || found || !self.searching_for_proxy) { + return Promise.resolve(); + } + + return Promise.resolve( + $.ajax({ + url: url + '/hw_proxy/hello', + method: 'GET', + timeout: 400, + }).then(function () { + found = true; + update_progress(); + return Promise.resolve(url); + }, function () { + update_progress(); + return thread(); + }) + ); + } + + this.searching_for_proxy = true; + + var len = Math.min(parallel, urls.length); + for(i = 0; i < len; i++){ + threads.push(thread()); + } + + return new Promise(function (resolve, reject) { + Promise.all(threads).then(function(results){ + var urls = []; + for(var i = 0; i < results.length; i++){ + if(results[i]){ + urls.push(results[i]); + } + } + resolve(urls[0]); + }); + }); + }, + + stop_searching: function(){ + this.searching_for_proxy = false; + this.set_connection_status('disconnected'); + }, + + // this allows the client to be notified when a proxy call is made. The notification + // callback will be executed with the same arguments as the proxy call + add_notification: function(name, callback){ + if(!this.notifications[name]){ + this.notifications[name] = []; + } + this.notifications[name].push(callback); + }, + + /** + * Returns the weight on the scale. + * + * @returns {Promise<Object>} + */ + scale_read: function () { + var self = this; + if (self.use_debug_weight) { + return Promise.resolve({weight:this.debug_weight, unit:'Kg', info:'ok'}); + } + return new Promise(function (resolve, reject) { + self.message('scale_read',{}) + .then(function (weight) { + resolve(weight); + }, function () { //failed to read weight + resolve({weight:0.0, unit:'Kg', info:'ok'}); + }); + }); + }, + + // sets a custom weight, ignoring the proxy returned value. + debug_set_weight: function(kg){ + this.use_debug_weight = true; + this.debug_weight = kg; + }, + + // resets the custom weight and re-enable listening to the proxy for weight values + debug_reset_weight: function(){ + this.use_debug_weight = false; + this.debug_weight = 0; + }, + + update_customer_facing_display: function(html) { + if (this.posbox_supports_display) { + return this.message('customer_facing_display', + { html: html }, + { timeout: 5000 }); + } + }, + + /** + * @param {string} html + * @returns {Promise} + */ + take_ownership_over_client_screen: function(html) { + return this.message("take_control", { html: html }); + }, + + /** + * @returns {Promise} + */ + test_ownership_of_client_screen: function() { + if (this.connection) { + return this.message("test_ownership", {}); + } + return Promise.reject({abort: true}); + }, + + // asks the proxy to log some information, as with the debug.log you can provide several arguments. + log: function(){ + return this.message('log',{'arguments': _.toArray(arguments)}); + }, + +}); + +return { + JobQueue: JobQueue, + ProxyDevice: ProxyDevice, +}; + +}); diff --git a/addons/point_of_sale/static/src/js/keyboard.js b/addons/point_of_sale/static/src/js/keyboard.js new file mode 100644 index 00000000..a9ea1f55 --- /dev/null +++ b/addons/point_of_sale/static/src/js/keyboard.js @@ -0,0 +1,207 @@ +odoo.define('point_of_sale.keyboard', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +// ---------- OnScreen Keyboard Widget ---------- +// A Widget that displays an onscreen keyboard. +// There are two options when creating the widget : +// +// * 'keyboard_model' : 'simple' (default) | 'full' +// The 'full' emulates a PC keyboard, while 'simple' emulates an 'android' one. +// +// * 'input_selector : (default: '.searchbox input') +// defines the dom element that the keyboard will write to. +// +// The widget is initially hidden. It can be shown with this.show(), and is +// automatically shown when the input_selector gets focused. + +var OnscreenKeyboardWidget = Widget.extend({ + template: 'OnscreenKeyboardSimple', + init: function(parent, options){ + this._super(parent,options); + options = options || {}; + + this.keyboard_model = options.keyboard_model || 'simple'; + if(this.keyboard_model === 'full'){ + this.template = 'OnscreenKeyboardFull'; + } + + this.input_selector = options.input_selector || '.searchbox input'; + this.$target = null; + + //Keyboard state + this.capslock = false; + this.shift = false; + this.numlock = false; + }, + + connect : function(target){ + var self = this; + this.$target = $(target); + this.$target.focus(function(){self.show();}); + }, + generateEvent: function(type,key){ + var event = document.createEvent("KeyboardEvent"); + var initMethod = event.initKeyboardEvent ? 'initKeyboardEvent' : 'initKeyEvent'; + event[initMethod]( type, + true, //bubbles + true, //cancelable + window, //viewArg + false, //ctrl + false, //alt + false, //shift + false, //meta + ((typeof key.code === 'undefined') ? key.char.charCodeAt(0) : key.code), + ((typeof key.char === 'undefined') ? String.fromCharCode(key.code) : key.char) + ); + return event; + + }, + + // Write a character to the input zone + writeCharacter: function(character){ + var input = this.$target[0]; + input.dispatchEvent(this.generateEvent('keypress',{char: character})); + if(character !== '\n'){ + input.value += character; + } + input.dispatchEvent(this.generateEvent('keyup',{char: character})); + }, + + // Removes the last character from the input zone. + deleteCharacter: function(){ + var input = this.$target[0]; + input.dispatchEvent(this.generateEvent('keypress',{code: 8})); + input.value = input.value.substr(0, input.value.length -1); + input.dispatchEvent(this.generateEvent('keyup',{code: 8})); + }, + + // Clears the content of the input zone. + deleteAllCharacters: function(){ + var input = this.$target[0]; + if(input.value){ + input.dispatchEvent(this.generateEvent('keypress',{code: 8})); + input.value = ""; + input.dispatchEvent(this.generateEvent('keyup',{code: 8})); + } + }, + + // Makes the keyboard show and slide from the bottom of the screen. + show: function(){ + $('.keyboard_frame').show().css({'height':'235px'}); + }, + + // Makes the keyboard hide by sliding to the bottom of the screen. + hide: function(){ + $('.keyboard_frame') + .css({'height':'0'}) + .hide(); + this.reset(); + }, + + //What happens when the shift key is pressed : toggle case, remove capslock + toggleShift: function(){ + $('.letter').toggleClass('uppercase'); + $('.symbol span').toggle(); + + this.shift = (this.shift === true) ? false : true; + this.capslock = false; + }, + + //what happens when capslock is pressed : toggle case, set capslock + toggleCapsLock: function(){ + $('.letter').toggleClass('uppercase'); + this.capslock = true; + }, + + //What happens when numlock is pressed : toggle symbols and numlock label + toggleNumLock: function(){ + $('.symbol span').toggle(); + $('.numlock span').toggle(); + this.numlock = (this.numlock === true ) ? false : true; + }, + + //After a key is pressed, shift is disabled. + removeShift: function(){ + if (this.shift === true) { + $('.symbol span').toggle(); + if (this.capslock === false) $('.letter').toggleClass('uppercase'); + + this.shift = false; + } + }, + + // Resets the keyboard to its original state; capslock: false, shift: false, numlock: false + reset: function(){ + if(this.shift){ + this.toggleShift(); + } + if(this.capslock){ + this.toggleCapsLock(); + } + if(this.numlock){ + this.toggleNumLock(); + } + }, + + //called after the keyboard is in the DOM, sets up the key bindings. + start: function(){ + var self = this; + + //this.show(); + + + $('.close_button').click(function(){ + self.deleteAllCharacters(); + self.hide(); + }); + + // Keyboard key click handling + $('.keyboard li').click(function(){ + + var $this = $(this), + character = $this.html(); // If it's a lowercase letter, nothing happens to this variable + + if ($this.hasClass('left-shift') || $this.hasClass('right-shift')) { + self.toggleShift(); + return false; + } + + if ($this.hasClass('capslock')) { + self.toggleCapsLock(); + return false; + } + + if ($this.hasClass('delete')) { + self.deleteCharacter(); + return false; + } + + if ($this.hasClass('numlock')){ + self.toggleNumLock(); + return false; + } + + // Special characters + if ($this.hasClass('symbol')) character = $('span:visible', $this).html(); + if ($this.hasClass('space')) character = ' '; + if ($this.hasClass('tab')) character = "\t"; + if ($this.hasClass('return')) character = "\n"; + + // Uppercase letter + if ($this.hasClass('uppercase')) character = character.toUpperCase(); + + // Remove shift once a key is clicked. + self.removeShift(); + + self.writeCharacter(character); + }); + }, +}); + +return { + OnscreenKeyboardWidget: OnscreenKeyboardWidget, +}; + +}); diff --git a/addons/point_of_sale/static/src/js/main.js b/addons/point_of_sale/static/src/js/main.js new file mode 100644 index 00000000..346a6167 --- /dev/null +++ b/addons/point_of_sale/static/src/js/main.js @@ -0,0 +1,49 @@ +odoo.define('web.web_client', function (require) { + 'use strict'; + + const AbstractService = require('web.AbstractService'); + const env = require('web.env'); + const WebClient = require('web.AbstractWebClient'); + const Chrome = require('point_of_sale.Chrome'); + const Registries = require('point_of_sale.Registries'); + const { configureGui } = require('point_of_sale.Gui'); + + owl.config.mode = env.isDebug() ? 'dev' : 'prod'; + owl.Component.env = env; + + Registries.Component.add(owl.misc.Portal); + + function setupResponsivePlugin(env) { + const isMobile = () => window.innerWidth <= 768; + env.isMobile = isMobile(); + const updateEnv = owl.utils.debounce(() => { + if (env.isMobile !== isMobile()) { + env.isMobile = !env.isMobile; + env.qweb.forceUpdate(); + } + }, 15); + window.addEventListener("resize", updateEnv); + } + + setupResponsivePlugin(owl.Component.env); + + async function startPosApp(webClient) { + Registries.Component.freeze(); + await env.session.is_bound; + env.qweb.addTemplates(env.session.owlTemplates); + env.bus = new owl.core.EventBus(); + await owl.utils.whenReady(); + await webClient.setElement(document.body); + await webClient.start(); + webClient.isStarted = true; + const chrome = new (Registries.Component.get(Chrome))(null, { webClient }); + await chrome.mount(document.querySelector('.o_action_manager')); + await chrome.start(); + configureGui({ component: chrome }); + } + + AbstractService.prototype.deployServices(env); + const webClient = new WebClient(); + startPosApp(webClient); + return webClient; +}); diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js new file mode 100644 index 00000000..100596d5 --- /dev/null +++ b/addons/point_of_sale/static/src/js/models.js @@ -0,0 +1,3514 @@ +odoo.define('point_of_sale.models', function (require) { +"use strict"; + +const { Context } = owl; +var BarcodeParser = require('barcodes.BarcodeParser'); +var BarcodeReader = require('point_of_sale.BarcodeReader'); +var PosDB = require('point_of_sale.DB'); +var devices = require('point_of_sale.devices'); +var concurrency = require('web.concurrency'); +var config = require('web.config'); +var core = require('web.core'); +var field_utils = require('web.field_utils'); +var time = require('web.time'); +var utils = require('web.utils'); + +var QWeb = core.qweb; +var _t = core._t; +var Mutex = concurrency.Mutex; +var round_di = utils.round_decimals; +var round_pr = utils.round_precision; + +var exports = {}; + +// The PosModel contains the Point Of Sale's representation of the backend. +// Since the PoS must work in standalone ( Without connection to the server ) +// it must contains a representation of the server's PoS backend. +// (taxes, product list, configuration options, etc.) this representation +// is fetched and stored by the PosModel at the initialisation. +// this is done asynchronously, a ready deferred alows the GUI to wait interactively +// for the loading to be completed +// There is a single instance of the PosModel for each Front-End instance, it is usually called +// 'pos' and is available to all widgets extending PosWidget. + +exports.PosModel = Backbone.Model.extend({ + initialize: function(attributes) { + Backbone.Model.prototype.initialize.call(this, attributes); + var self = this; + this.flush_mutex = new Mutex(); // used to make sure the orders are sent to the server once at time + + this.env = this.get('env'); + this.rpc = this.get('rpc'); + this.session = this.get('session'); + this.do_action = this.get('do_action'); + this.setLoadingMessage = this.get('setLoadingMessage'); + this.setLoadingProgress = this.get('setLoadingProgress'); + this.showLoadingSkip = this.get('showLoadingSkip'); + + this.proxy = new devices.ProxyDevice(this); // used to communicate to the hardware devices via a local proxy + this.barcode_reader = new BarcodeReader({'pos': this, proxy:this.proxy}); + + this.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy + this.db = new PosDB(); // a local database used to search trough products and categories & store pending orders + this.debug = config.isDebug(); //debug mode + + // Business data; loaded from the server at launch + this.company_logo = null; + this.company_logo_base64 = ''; + this.currency = null; + this.company = null; + this.user = null; + this.users = []; + this.employee = {name: null, id: null, barcode: null, user_id:null, pin:null}; + this.employees = []; + this.partners = []; + this.taxes = []; + this.pos_session = null; + this.config = null; + this.units = []; + this.units_by_id = {}; + this.uom_unit_id = null; + this.default_pricelist = null; + this.order_sequence = 1; + window.posmodel = this; + + // Object mapping the order's name (which contains the uid) to it's server_id after + // validation (order paid then sent to the backend). + this.validated_orders_name_server_id_map = {}; + + // Extract the config id from the url. + var given_config = new RegExp('[\?&]config_id=([^&#]*)').exec(window.location.href); + this.config_id = given_config && given_config[1] && parseInt(given_config[1]) || false; + + // these dynamic attributes can be watched for change by other models or widgets + this.set({ + 'synch': { status:'connected', pending:0 }, + 'orders': new OrderCollection(), + 'selectedOrder': null, + 'selectedClient': null, + 'cashier': null, + 'selectedCategoryId': null, + }); + + this.get('orders').on('remove', function(order,_unused_,options){ + self.on_removed_order(order,options.index,options.reason); + }); + + // Forward the 'client' attribute on the selected order to 'selectedClient' + function update_client() { + var order = self.get_order(); + this.set('selectedClient', order ? order.get_client() : null ); + } + this.get('orders').on('add remove change', update_client, this); + this.on('change:selectedOrder', update_client, this); + + // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched, + // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched. + // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used. + this.ready = this.load_server_data().then(function(){ + return self.after_load_server_data(); + }); + }, + after_load_server_data: function(){ + this.load_orders(); + this.set_start_order(); + if(this.config.use_proxy){ + if (this.config.iface_customer_facing_display) { + this.on('change:selectedOrder', this.send_current_order_to_customer_facing_display, this); + } + + return this.connect_to_proxy(); + } + return Promise.resolve(); + }, + // releases ressources holds by the model at the end of life of the posmodel + destroy: function(){ + // FIXME, should wait for flushing, return a deferred to indicate successfull destruction + // this.flush(); + this.proxy.disconnect(); + this.barcode_reader.disconnect_from_proxy(); + }, + + connect_to_proxy: function () { + var self = this; + return new Promise(function (resolve, reject) { + self.barcode_reader.disconnect_from_proxy(); + self.setLoadingMessage(_t('Connecting to the IoT Box'), 0); + self.showLoadingSkip(function () { + self.proxy.stop_searching(); + }); + self.proxy.autoconnect({ + force_ip: self.config.proxy_ip || undefined, + progress: function(prog){ + self.setLoadingProgress(prog); + }, + }).then( + function () { + if (self.config.iface_scan_via_proxy) { + self.barcode_reader.connect_to_proxy(); + } + resolve(); + }, + function (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: _t('HTTPS connection to IoT Box failed'), + body: _.str.sprintf( + _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'), + url + ), + popup: 'alert', + }); + } else { + resolve(); + } + } + ); + }); + }, + + // Server side model loaders. This is the list of the models that need to be loaded from + // the server. The models are loaded one by one by this list's order. The 'loaded' callback + // is used to store the data in the appropriate place once it has been loaded. This callback + // can return a promise that will pause the loading of the next module. + // a shared temporary dictionary is available for loaders to communicate private variables + // used during loading such as object ids, etc. + models: [ + { + label: 'version', + loaded: function (self) { + return self.session.rpc('/web/webclient/version_info',{}).then(function (version) { + self.version = version; + }); + }, + + },{ + model: 'res.company', + fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'state_id', 'tax_calculation_rounding_method'], + ids: function(self){ return [self.session.user_context.allowed_company_ids[0]]; }, + loaded: function(self,companies){ self.company = companies[0]; }, + },{ + model: 'decimal.precision', + fields: ['name','digits'], + loaded: function(self,dps){ + self.dp = {}; + for (var i = 0; i < dps.length; i++) { + self.dp[dps[i].name] = dps[i].digits; + } + }, + },{ + model: 'uom.uom', + fields: [], + domain: null, + context: function(self){ return { active_test: false }; }, + loaded: function(self,units){ + self.units = units; + _.each(units, function(unit){ + self.units_by_id[unit.id] = unit; + }); + } + },{ + model: 'ir.model.data', + fields: ['res_id'], + domain: function(){ return [['name', '=', 'product_uom_unit']]; }, + loaded: function(self,unit){ + self.uom_unit_id = unit[0].res_id; + } + },{ + model: 'res.partner', + label: 'load_partners', + fields: ['name','street','city','state_id','country_id','vat','lang', + 'phone','zip','mobile','email','barcode','write_date', + 'property_account_position_id','property_product_pricelist'], + loaded: function(self,partners){ + self.partners = partners; + self.db.add_partners(partners); + }, + },{ + model: 'res.country.state', + fields: ['name', 'country_id'], + loaded: function(self,states){ + self.states = states; + }, + },{ + model: 'res.country', + fields: ['name', 'vat_label', 'code'], + loaded: function(self,countries){ + self.countries = countries; + self.company.country = null; + for (var i = 0; i < countries.length; i++) { + if (countries[i].id === self.company.country_id[0]){ + self.company.country = countries[i]; + } + } + }, + },{ + model: 'res.lang', + fields: ['name', 'code'], + loaded: function (self, langs){ + self.langs = langs; + }, + },{ + model: 'account.tax', + fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], + domain: function(self) {return [['company_id', '=', self.company && self.company.id || false]]}, + loaded: function(self, taxes){ + self.taxes = taxes; + self.taxes_by_id = {}; + _.each(taxes, function(tax){ + self.taxes_by_id[tax.id] = tax; + }); + _.each(self.taxes_by_id, function(tax) { + tax.children_tax_ids = _.map(tax.children_tax_ids, function (child_tax_id) { + return self.taxes_by_id[child_tax_id]; + }); + }); + return new Promise(function (resolve, reject) { + var tax_ids = _.pluck(self.taxes, 'id'); + self.rpc({ + model: 'account.tax', + method: 'get_real_tax_amount', + args: [tax_ids], + }).then(function (taxes) { + _.each(taxes, function (tax) { + self.taxes_by_id[tax.id].amount = tax.amount; + }); + resolve(); + }); + }); + }, + },{ + model: 'pos.session', + fields: ['id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number', 'payment_method_ids', 'cash_register_id', 'state'], + domain: function(self){ + var domain = [ + ['state','in',['opening_control','opened']], + ['rescue', '=', false], + ]; + if (self.config_id) domain.push(['config_id', '=', self.config_id]); + return domain; + }, + loaded: function(self, pos_sessions, tmp){ + self.pos_session = pos_sessions[0]; + self.pos_session.login_number = odoo.login_number; + self.config_id = self.config_id || self.pos_session && self.pos_session.config_id[0]; + tmp.payment_method_ids = pos_sessions[0].payment_method_ids; + }, + },{ + model: 'pos.config', + fields: [], + domain: function(self){ return [['id','=', self.config_id]]; }, + loaded: function(self,configs){ + self.config = configs[0]; + self.config.use_proxy = self.config.is_posbox && ( + self.config.iface_electronic_scale || + self.config.iface_print_via_proxy || + self.config.iface_scan_via_proxy || + self.config.iface_customer_facing_display); + + self.db.set_uuid(self.config.uuid); + self.set_cashier(self.get_cashier()); + // We need to do it here, since only then the local storage has the correct uuid + self.db.save('pos_session_id', self.pos_session.id); + + var orders = self.db.get_orders(); + for (var i = 0; i < orders.length; i++) { + self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1); + } + }, + },{ + model: 'stock.picking.type', + fields: ['use_create_lots', 'use_existing_lots'], + domain: function(self){ return [['id', '=', self.config.picking_type_id[0]]]; }, + loaded: function(self, picking_type) { + self.picking_type = picking_type[0]; + }, + },{ + model: 'res.users', + fields: ['name','company_id', 'id', 'groups_id', 'lang'], + domain: function(self){ return [['company_ids', 'in', self.config.company_id[0]],'|', ['groups_id','=', self.config.group_pos_manager_id[0]],['groups_id','=', self.config.group_pos_user_id[0]]]; }, + loaded: function(self,users){ + users.forEach(function(user) { + user.role = 'cashier'; + user.groups_id.some(function(group_id) { + if (group_id === self.config.group_pos_manager_id[0]) { + user.role = 'manager'; + return true; + } + }); + if (user.id === self.session.uid) { + self.user = user; + self.employee.name = user.name; + self.employee.role = user.role; + self.employee.user_id = [user.id, user.name]; + } + }); + self.users = users; + self.employees = [self.employee]; + self.set_cashier(self.employee); + }, + },{ + model: 'product.pricelist', + fields: ['name', 'display_name', 'discount_policy'], + domain: function(self) { + if (self.config.use_pricelist) { + return [['id', 'in', self.config.available_pricelist_ids]]; + } else { + return [['id', '=', self.config.pricelist_id[0]]]; + } + }, + loaded: function(self, pricelists){ + _.map(pricelists, function (pricelist) { pricelist.items = []; }); + self.default_pricelist = _.findWhere(pricelists, {id: self.config.pricelist_id[0]}); + self.pricelists = pricelists; + }, + },{ + model: 'account.bank.statement', + fields: ['id', 'balance_start'], + domain: function(self){ return [['id', '=', self.pos_session.cash_register_id[0]]]; }, + loaded: function(self, statement){ + self.bank_statement = statement[0]; + }, + },{ + model: 'product.pricelist.item', + domain: function(self) { return [['pricelist_id', 'in', _.pluck(self.pricelists, 'id')]]; }, + loaded: function(self, pricelist_items){ + var pricelist_by_id = {}; + _.each(self.pricelists, function (pricelist) { + pricelist_by_id[pricelist.id] = pricelist; + }); + + _.each(pricelist_items, function (item) { + var pricelist = pricelist_by_id[item.pricelist_id[0]]; + pricelist.items.push(item); + item.base_pricelist = pricelist_by_id[item.base_pricelist_id[0]]; + }); + }, + },{ + model: 'product.category', + fields: ['name', 'parent_id'], + loaded: function(self, product_categories){ + var category_by_id = {}; + _.each(product_categories, function (category) { + category_by_id[category.id] = category; + }); + _.each(product_categories, function (category) { + category.parent = category_by_id[category.parent_id[0]]; + }); + + self.product_categories = product_categories; + }, + },{ + model: 'res.currency', + fields: ['name','symbol','position','rounding','rate'], + ids: function(self){ return [self.config.currency_id[0], self.company.currency_id[0]]; }, + loaded: function(self, currencies){ + self.currency = currencies[0]; + if (self.currency.rounding > 0 && self.currency.rounding < 1) { + self.currency.decimals = Math.ceil(Math.log(1.0 / self.currency.rounding) / Math.log(10)); + } else { + self.currency.decimals = 0; + } + + self.company_currency = currencies[1]; + }, + },{ + model: 'pos.category', + fields: ['id', 'name', 'parent_id', 'child_id', 'write_date'], + domain: function(self) { + return self.config.limit_categories && self.config.iface_available_categ_ids.length ? [['id', 'in', self.config.iface_available_categ_ids]] : []; + }, + loaded: function(self, categories){ + self.db.add_categories(categories); + }, + },{ + model: 'product.product', + fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', + 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', + 'product_tmpl_id','tracking', 'write_date', 'available_in_pos', 'attribute_line_ids'], + order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }), + domain: function(self){ + var domain = ['&', '&', ['sale_ok','=',true],['available_in_pos','=',true],'|',['company_id','=',self.config.company_id[0]],['company_id','=',false]]; + if (self.config.limit_categories && self.config.iface_available_categ_ids.length) { + domain.unshift('&'); + domain.push(['pos_categ_id', 'in', self.config.iface_available_categ_ids]); + } + if (self.config.iface_tipproduct){ + domain.unshift(['id', '=', self.config.tip_product_id[0]]); + domain.unshift('|'); + } + return domain; + }, + context: function(self){ return { display_default_code: false }; }, + loaded: function(self, products){ + var using_company_currency = self.config.currency_id[0] === self.company.currency_id[0]; + var conversion_rate = self.currency.rate / self.company_currency.rate; + self.db.add_products(_.map(products, function (product) { + if (!using_company_currency) { + product.lst_price = round_pr(product.lst_price * conversion_rate, self.currency.rounding); + } + product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]}); + product.pos = self; + return new exports.Product({}, product); + })); + }, + },{ + model: 'product.attribute', + fields: ['name', 'display_type'], + condition: function (self) { return self.config.product_configurator; }, + domain: function(){ return [['create_variant', '=', 'no_variant']]; }, + loaded: function(self, product_attributes, tmp) { + tmp.product_attributes_by_id = {}; + _.map(product_attributes, function (product_attribute) { + tmp.product_attributes_by_id[product_attribute.id] = product_attribute; + }); + } + },{ + model: 'product.attribute.value', + fields: ['name', 'attribute_id', 'is_custom', 'html_color'], + condition: function (self) { return self.config.product_configurator; }, + domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, + loaded: function(self, pavs, tmp) { + tmp.pav_by_id = {}; + _.map(pavs, function (pav) { + tmp.pav_by_id[pav.id] = pav; + }); + } + }, { + model: 'product.template.attribute.value', + fields: ['product_attribute_value_id', 'attribute_id', 'attribute_line_id', 'price_extra'], + condition: function (self) { return self.config.product_configurator; }, + domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, + loaded: function(self, ptavs, tmp) { + self.attributes_by_ptal_id = {}; + _.map(ptavs, function (ptav) { + if (!self.attributes_by_ptal_id[ptav.attribute_line_id[0]]){ + self.attributes_by_ptal_id[ptav.attribute_line_id[0]] = { + id: ptav.attribute_line_id[0], + name: tmp.product_attributes_by_id[ptav.attribute_id[0]].name, + display_type: tmp.product_attributes_by_id[ptav.attribute_id[0]].display_type, + values: [], + }; + } + self.attributes_by_ptal_id[ptav.attribute_line_id[0]].values.push({ + id: ptav.product_attribute_value_id[0], + name: tmp.pav_by_id[ptav.product_attribute_value_id[0]].name, + is_custom: tmp.pav_by_id[ptav.product_attribute_value_id[0]].is_custom, + html_color: tmp.pav_by_id[ptav.product_attribute_value_id[0]].html_color, + price_extra: ptav.price_extra, + }); + }); + } + },{ + model: 'account.cash.rounding', + fields: ['name', 'rounding', 'rounding_method'], + domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; }, + loaded: function(self, cash_rounding) { + self.cash_rounding = cash_rounding; + } + },{ + model: 'pos.payment.method', + fields: ['name', 'is_cash_count', 'use_payment_terminal'], + domain: function(self){return ['|',['active', '=', false], ['active', '=', true]]; }, + loaded: function(self, payment_methods) { + self.payment_methods = payment_methods.sort(function(a,b){ + // prefer cash payment_method to be first in the list + if (a.is_cash_count && !b.is_cash_count) { + return -1; + } else if (!a.is_cash_count && b.is_cash_count) { + return 1; + } else { + return a.id - b.id; + } + }); + self.payment_methods_by_id = {}; + _.each(self.payment_methods, function(payment_method) { + self.payment_methods_by_id[payment_method.id] = payment_method; + + var PaymentInterface = self.electronic_payment_interfaces[payment_method.use_payment_terminal]; + if (PaymentInterface) { + payment_method.payment_terminal = new PaymentInterface(self, payment_method); + } + }); + } + },{ + model: 'account.fiscal.position', + fields: [], + domain: function(self){ return [['id','in',self.config.fiscal_position_ids]]; }, + loaded: function(self, fiscal_positions){ + self.fiscal_positions = fiscal_positions; + } + }, { + model: 'account.fiscal.position.tax', + fields: [], + domain: function(self){ + var fiscal_position_tax_ids = []; + + self.fiscal_positions.forEach(function (fiscal_position) { + fiscal_position.tax_ids.forEach(function (tax_id) { + fiscal_position_tax_ids.push(tax_id); + }); + }); + + return [['id','in',fiscal_position_tax_ids]]; + }, + loaded: function(self, fiscal_position_taxes){ + self.fiscal_position_taxes = fiscal_position_taxes; + self.fiscal_positions.forEach(function (fiscal_position) { + fiscal_position.fiscal_position_taxes_by_id = {}; + fiscal_position.tax_ids.forEach(function (tax_id) { + var fiscal_position_tax = _.find(fiscal_position_taxes, function (fiscal_position_tax) { + return fiscal_position_tax.id === tax_id; + }); + + fiscal_position.fiscal_position_taxes_by_id[fiscal_position_tax.id] = fiscal_position_tax; + }); + }); + } + }, { + label: 'fonts', + loaded: function(){ + return new Promise(function (resolve, reject) { + // Waiting for fonts to be loaded to prevent receipt printing + // from printing empty receipt while loading Inconsolata + // ( The font used for the receipt ) + waitForWebfonts(['Lato','Inconsolata'], function () { + resolve(); + }); + // The JS used to detect font loading is not 100% robust, so + // do not wait more than 5sec + setTimeout(resolve, 5000); + }); + }, + },{ + label: 'pictures', + loaded: function (self) { + self.company_logo = new Image(); + return new Promise(function (resolve, reject) { + self.company_logo.onload = function () { + var img = self.company_logo; + var ratio = 1; + var targetwidth = 300; + var maxheight = 150; + if( img.width !== targetwidth ){ + ratio = targetwidth / 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); + var c = document.createElement('canvas'); + c.width = width; + c.height = height; + var ctx = c.getContext('2d'); + ctx.drawImage(self.company_logo,0,0, width, height); + + self.company_logo_base64 = c.toDataURL(); + resolve(); + }; + self.company_logo.onerror = function () { + reject(); + }; + self.company_logo.crossOrigin = "anonymous"; + self.company_logo.src = '/web/binary/company_logo' + '?dbname=' + self.session.db + '&company=' + self.company.id + '&_' + Math.random(); + }); + }, + }, { + label: 'barcodes', + loaded: function(self) { + var barcode_parser = new BarcodeParser({'nomenclature_id': self.config.barcode_nomenclature_id}); + self.barcode_reader.set_barcode_parser(barcode_parser); + return barcode_parser.is_loaded(); + }, + }, + ], + + // loads all the needed data on the sever. returns a promise indicating when all the data has loaded. + load_server_data: function(){ + var self = this; + var progress = 0; + var progress_step = 1.0 / self.models.length; + var tmp = {}; // this is used to share a temporary state between models loaders + + var loaded = new Promise(function (resolve, reject) { + function load_model(index) { + if (index >= self.models.length) { + resolve(); + } else { + var model = self.models[index]; + self.setLoadingMessage(_t('Loading')+' '+(model.label || model.model || ''), progress); + + var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true; + if (!cond) { + load_model(index+1); + return; + } + + var fields = typeof model.fields === 'function' ? model.fields(self,tmp) : model.fields; + var domain = typeof model.domain === 'function' ? model.domain(self,tmp) : model.domain; + var context = typeof model.context === 'function' ? model.context(self,tmp) : model.context || {}; + var ids = typeof model.ids === 'function' ? model.ids(self,tmp) : model.ids; + var order = typeof model.order === 'function' ? model.order(self,tmp): model.order; + progress += progress_step; + + if( model.model ){ + var params = { + model: model.model, + context: _.extend(context, self.session.user_context || {}), + }; + + if (model.ids) { + params.method = 'read'; + params.args = [ids, fields]; + } else { + params.method = 'search_read'; + params.domain = domain; + params.fields = fields; + params.orderBy = order; + } + + self.rpc(params).then(function (result) { + try { // catching exceptions in model.loaded(...) + Promise.resolve(model.loaded(self, result, tmp)) + .then(function () { load_model(index + 1); }, + function (err) { reject(err); }); + } catch (err) { + console.error(err.message, err.stack); + reject(err); + } + }, function (err) { + reject(err); + }); + } else if (model.loaded) { + try { // catching exceptions in model.loaded(...) + Promise.resolve(model.loaded(self, tmp)) + .then(function () { load_model(index +1); }, + function (err) { reject(err); }); + } catch (err) { + reject(err); + } + } else { + load_model(index + 1); + } + } + } + + try { + return load_model(0); + } catch (err) { + return Promise.reject(err); + } + }); + + return loaded; + }, + + prepare_new_partners_domain: function(){ + return [['write_date','>', this.db.get_partner_write_date()]]; + }, + + // reload the list of partner, returns as a promise that resolves if there were + // updated partners, and fails if not + load_new_partners: function(){ + var self = this; + return new Promise(function (resolve, reject) { + var fields = _.find(self.models, function(model){ return model.label === 'load_partners'; }).fields; + var domain = self.prepare_new_partners_domain(); + self.rpc({ + model: 'res.partner', + method: 'search_read', + args: [domain, fields], + }, { + timeout: 3000, + shadow: true, + }) + .then(function (partners) { + if (self.db.add_partners(partners)) { // check if the partners we got were real updates + resolve(); + } else { + reject('Failed in updating partners.'); + } + }, function (type, err) { reject(); }); + }); + }, + + // this is called when an order is removed from the order collection. It ensures that there is always an existing + // order and a valid selected order + on_removed_order: function(removed_order,index,reason){ + var order_list = this.get_order_list(); + if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){ + // when we intentionally remove an unfinished order, and there is another existing one + this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true }); + }else{ + // when the order was automatically removed after completion, + // or when we intentionally delete the only concurrent order + this.add_new_order({ silent: true }); + } + }, + + // returns the user who is currently the cashier for this point of sale + get_cashier: function(){ + // reset the cashier to the current user if session is new + if (this.db.load('pos_session_id') !== this.pos_session.id) { + this.set_cashier(this.employee); + } + return this.db.get_cashier() || this.get('cashier') || this.employee; + }, + // changes the current cashier + set_cashier: function(employee){ + this.set('cashier', employee); + this.db.set_cashier(this.get('cashier')); + }, + // creates a new empty order and sets it as the current order + add_new_order: function(options){ + var order = new exports.Order({},{pos:this}); + this.get('orders').add(order); + this.set('selectedOrder', order, options); + return order; + }, + /** + * Load the locally saved unpaid orders for this PoS Config. + * + * First load all orders belonging to the current session. + * Second load all orders belonging to the same config but from other sessions, + * Only if tho order has orderlines. + */ + load_orders: function(){ + var jsons = this.db.get_unpaid_orders(); + var orders = []; + + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id === this.pos_session.id) { + orders.push(new exports.Order({},{ + pos: this, + json: json, + })); + } + } + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id !== this.pos_session.id && json.lines.length > 0) { + orders.push(new exports.Order({},{ + pos: this, + json: json, + })); + } else if (json.pos_session_id !== this.pos_session.id) { + this.db.remove_unpaid_order(jsons[i]); + } + } + + orders = orders.sort(function(a,b){ + return a.sequence_number - b.sequence_number; + }); + + if (orders.length) { + this.get('orders').add(orders); + } + }, + + set_start_order: function(){ + var orders = this.get('orders').models; + + if (orders.length && !this.get('selectedOrder')) { + this.set('selectedOrder',orders[0]); + } else { + this.add_new_order(); + } + }, + + // return the current order + get_order: function(){ + return this.get('selectedOrder'); + }, + + get_client: function() { + var order = this.get_order(); + if (order) { + return order.get_client(); + } + return null; + }, + + // change the current order + set_order: function(order, options){ + this.set({ selectedOrder: order }, options); + }, + + // return the list of unpaid orders + get_order_list: function(){ + return this.get('orders').models; + }, + + //removes the current order + delete_current_order: function(){ + var order = this.get_order(); + if (order) { + order.destroy({'reason':'abandon'}); + } + }, + + _convert_product_img_to_base64: function (product, url) { + return new Promise(function (resolve, reject) { + var img = new Image(); + + img.onload = function () { + var canvas = document.createElement('CANVAS'); + var ctx = canvas.getContext('2d'); + + canvas.height = this.height; + canvas.width = this.width; + ctx.drawImage(this,0,0); + + var dataURL = canvas.toDataURL('image/jpeg'); + product.image_base64 = dataURL; + canvas = null; + + resolve(); + }; + img.crossOrigin = 'use-credentials'; + img.src = url; + }); + }, + + send_current_order_to_customer_facing_display: function() { + var self = this; + this.render_html_for_customer_facing_display().then(function (rendered_html) { + self.proxy.update_customer_facing_display(rendered_html); + }); + }, + + /** + * @returns {Promise<string>} + */ + render_html_for_customer_facing_display: function () { + var self = this; + var order = this.get_order(); + var rendered_html = this.config.customer_facing_display_html; + + // If we're using an external device like the IoT Box, we + // cannot get /web/image?model=product.product because the + // IoT Box is not logged in and thus doesn't have the access + // rights to access product.product. So instead we'll base64 + // encode it and embed it in the HTML. + var get_image_promises = []; + + if (order) { + order.get_orderlines().forEach(function (orderline) { + var product = orderline.product; + var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + + // only download and convert image if we haven't done it before + if (! product.image_base64) { + get_image_promises.push(self._convert_product_img_to_base64(product, image_url)); + } + }); + } + + // when all images are loaded in product.image_base64 + return Promise.all(get_image_promises).then(function () { + var rendered_order_lines = ""; + var rendered_payment_lines = ""; + var order_total_with_tax = self.format_currency(0); + + if (order) { + rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', { + 'orderlines': order.get_orderlines(), + 'pos': self, + }); + rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', { + 'order': order, + 'pos': self, + }); + order_total_with_tax = self.format_currency(order.get_total_with_tax()); + } + + var $rendered_html = $(rendered_html); + $rendered_html.find('.pos_orderlines_list').html(rendered_order_lines); + $rendered_html.find('.pos-total').find('.pos_total-amount').html(order_total_with_tax); + var pos_change_title = $rendered_html.find('.pos-change_title').text(); + $rendered_html.find('.pos-paymentlines').html(rendered_payment_lines); + $rendered_html.find('.pos-change_title').text(pos_change_title); + + // prop only uses the first element in a set of elements, + // and there's no guarantee that + // customer_facing_display_html is wrapped in a single + // root element. + rendered_html = _.reduce($rendered_html, function (memory, current_element) { + return memory + $(current_element).prop('outerHTML'); + }, ""); // initial memory of "" + + rendered_html = QWeb.render('CustomerFacingDisplayHead', { + origin: window.location.origin + }) + rendered_html; + return rendered_html; + }); + }, + + // saves the order locally and try to send it to the backend. + // it returns a promise that succeeds after having tried to send the order and all the other pending orders. + push_orders: function (order, opts) { + opts = opts || {}; + var self = this; + + if (order) { + this.db.add_order(order.export_as_JSON()); + } + + return new Promise(function (resolve, reject) { + self.flush_mutex.exec(function () { + var flushed = self._flush_orders(self.db.get_orders(), opts); + + flushed.then(resolve, reject); + + return flushed; + }); + }); + }, + + push_single_order: function (order, opts) { + opts = opts || {}; + const self = this; + const order_id = self.db.add_order(order.export_as_JSON()); + + return new Promise(function (resolve, reject) { + self.flush_mutex.exec(function () { + var order = self.db.get_order(order_id); + if (order){ + var flushed = self._flush_orders([order], opts); + } else { + var flushed = Promise.resolve([]); + } + flushed.then(resolve, reject); + + return flushed; + }); + }); + }, + + // saves the order locally and try to send it to the backend and make an invoice + // returns a promise that succeeds when the order has been posted and successfully generated + // an invoice. This method can fail in various ways: + // error-no-client: the order must have an associated partner_id. You can retry to make an invoice once + // this error is solved + // error-transfer: there was a connection error during the transfer. You can retry to make the invoice once + // the network connection is up + + push_and_invoice_order: function (order) { + var self = this; + var invoiced = new Promise(function (resolveInvoiced, rejectInvoiced) { + if(!order.get_client()){ + rejectInvoiced({code:400, message:'Missing Customer', data:{}}); + } + else { + var order_id = self.db.add_order(order.export_as_JSON()); + + self.flush_mutex.exec(function () { + var done = new Promise(function (resolveDone, rejectDone) { + // send the order to the server + // we have a 30 seconds timeout on this push. + // FIXME: if the server takes more than 30 seconds to accept the order, + // the client will believe it wasn't successfully sent, and very bad + // things will happen as a duplicate will be sent next time + // so we must make sure the server detects and ignores duplicated orders + + var transfer = self._flush_orders([self.db.get_order(order_id)], {timeout:30000, to_invoice:true}); + + transfer.catch(function (error) { + rejectInvoiced(error); + rejectDone(); + }); + + // on success, get the order id generated by the server + transfer.then(function(order_server_id){ + // generate the pdf and download it + if (order_server_id.length) { + self.do_action('point_of_sale.pos_invoice_report',{additional_context:{ + active_ids:order_server_id, + }}).then(function () { + resolveInvoiced(order_server_id); + resolveDone(); + }).guardedCatch(function (error) { + rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); + rejectDone(); + }); + } else if (order_server_id.length) { + resolveInvoiced(order_server_id); + resolveDone(); + } else { + // The order has been pushed separately in batch when + // the connection came back. + // The user has to go to the backend to print the invoice + rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); + rejectDone(); + } + }); + return done; + }); + }); + } + }); + + return invoiced; + }, + + // wrapper around the _save_to_server that updates the synch status widget + // Resolves to the backend ids of the synced orders. + _flush_orders: function(orders, options) { + var self = this; + this.set_synch('connecting', orders.length); + + return this._save_to_server(orders, options).then(function (server_ids) { + self.set_synch('connected'); + for (let i = 0; i < server_ids.length; i++) { + self.validated_orders_name_server_id_map[server_ids[i].pos_reference] = server_ids[i].id; + } + return _.pluck(server_ids, 'id'); + }).catch(function(error){ + self.set_synch(self.get('failed') ? 'error' : 'disconnected'); + return Promise.reject(error); + }); + }, + + set_synch: function(status, pending) { + if (['connected', 'connecting', 'error', 'disconnected'].indexOf(status) === -1) { + console.error(status, ' is not a known connection state.'); + } + pending = pending || this.db.get_orders().length + this.db.get_ids_to_remove_from_server().length; + this.set('synch', { status, pending }); + }, + + // send an array of orders to the server + // available options: + // - timeout: timeout for the rpc call in ms + // returns a promise that resolves with the list of + // server generated ids for the sent orders + _save_to_server: function (orders, options) { + if (!orders || !orders.length) { + return Promise.resolve([]); + } + + options = options || {}; + + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 30000 * orders.length; + + // Keep the order ids that are about to be sent to the + // backend. In between create_from_ui and the success callback + // new orders may have been added to it. + var order_ids_to_sync = _.pluck(orders, 'id'); + + // we try to send the order. shadow prevents a spinner if it takes too long. (unless we are sending an invoice, + // then we want to notify the user that we are waiting on something ) + var args = [_.map(orders, function (order) { + order.to_invoice = options.to_invoice || false; + return order; + })]; + args.push(options.draft || false); + return this.rpc({ + model: 'pos.order', + method: 'create_from_ui', + args: args, + kwargs: {context: this.session.user_context}, + }, { + timeout: timeout, + shadow: !options.to_invoice + }) + .then(function (server_ids) { + _.each(order_ids_to_sync, function (order_id) { + self.db.remove_order(order_id); + }); + self.set('failed',false); + return server_ids; + }).catch(function (reason){ + var error = reason.message; + console.warn('Failed to send orders:', orders); + if(error.code === 200 ){ // Business Logic Error, not a connection problem + // Hide error if already shown before ... + if ((!self.get('failed') || options.show_error) && !options.to_invoice) { + self.set('failed',error); + throw error; + } + } + throw error; + }); + }, + + /** + * Remove orders with given ids from the database. + * @param {array<number>} server_ids ids of the orders to be removed. + * @param {dict} options. + * @param {number} options.timeout optional timeout parameter for the rpc call. + * @return {Promise<array<number>>} returns a promise of the ids successfully removed. + */ + _remove_from_server: function (server_ids, options) { + options = options || {}; + if (!server_ids || !server_ids.length) { + return Promise.resolve([]); + } + + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length; + + return this.rpc({ + model: 'pos.order', + method: 'remove_from_ui', + args: [server_ids], + kwargs: {context: this.session.user_context}, + }, { + timeout: timeout, + shadow: true, + }) + .then(function (data) { + return self._post_remove_from_server(server_ids, data) + }).catch(function (reason){ + var error = reason.message; + if(error.code === 200 ){ // Business Logic Error, not a connection problem + //if warning do not need to display traceback!! + if (error.data.exception_type == 'warning') { + delete error.data.debug; + } + } + // important to throw error here and let the rendering component handle the + // error + console.warn('Failed to remove orders:', server_ids); + throw error; + }); + }, + + // to override + _post_remove_from_server(server_ids, data) { + this.db.set_ids_removed_from_server(server_ids); + return server_ids; + }, + + scan_product: function(parsed_code){ + var selectedOrder = this.get_order(); + var product = this.db.get_product_by_barcode(parsed_code.base_code); + + if(!product){ + return false; + } + + if(parsed_code.type === 'price'){ + selectedOrder.add_product(product, {price:parsed_code.value}); + }else if(parsed_code.type === 'weight'){ + selectedOrder.add_product(product, {quantity:parsed_code.value, merge:false}); + }else if(parsed_code.type === 'discount'){ + selectedOrder.add_product(product, {discount:parsed_code.value, merge:false}); + }else{ + selectedOrder.add_product(product); + } + return true; + }, + + // Exports the paid orders (the ones waiting for internet connection) + export_paid_orders: function() { + return JSON.stringify({ + 'paid_orders': this.db.get_orders(), + 'session': this.pos_session.name, + 'session_id': this.pos_session.id, + 'date': (new Date()).toUTCString(), + 'version': this.version.server_version_info, + },null,2); + }, + + // Exports the unpaid orders (the tabs) + export_unpaid_orders: function() { + return JSON.stringify({ + 'unpaid_orders': this.db.get_unpaid_orders(), + 'session': this.pos_session.name, + 'session_id': this.pos_session.id, + 'date': (new Date()).toUTCString(), + 'version': this.version.server_version_info, + },null,2); + }, + + // This imports paid or unpaid orders from a json file whose + // contents are provided as the string str. + // It returns a report of what could and what could not be + // imported. + import_orders: function(str) { + var json = JSON.parse(str); + var report = { + // Number of paid orders that were imported + paid: 0, + // Number of unpaid orders that were imported + unpaid: 0, + // Orders that were not imported because they already exist (uid conflict) + unpaid_skipped_existing: 0, + // Orders that were not imported because they belong to another session + unpaid_skipped_session: 0, + // The list of session ids to which skipped orders belong. + unpaid_skipped_sessions: [], + }; + + if (json.paid_orders) { + for (var i = 0; i < json.paid_orders.length; i++) { + this.db.add_order(json.paid_orders[i].data); + } + report.paid = json.paid_orders.length; + this.push_orders(); + } + + if (json.unpaid_orders) { + + var orders = []; + var existing = this.get_order_list(); + var existing_uids = {}; + var skipped_sessions = {}; + + for (var i = 0; i < existing.length; i++) { + existing_uids[existing[i].uid] = true; + } + + for (var i = 0; i < json.unpaid_orders.length; i++) { + var order = json.unpaid_orders[i]; + if (order.pos_session_id !== this.pos_session.id) { + report.unpaid_skipped_session += 1; + skipped_sessions[order.pos_session_id] = true; + } else if (existing_uids[order.uid]) { + report.unpaid_skipped_existing += 1; + } else { + orders.push(new exports.Order({},{ + pos: this, + json: order, + })); + } + } + + orders = orders.sort(function(a,b){ + return a.sequence_number - b.sequence_number; + }); + + if (orders.length) { + report.unpaid = orders.length; + this.get('orders').add(orders); + } + + report.unpaid_skipped_sessions = _.keys(skipped_sessions); + } + + return report; + }, + + _load_orders: function(){ + var jsons = this.db.get_unpaid_orders(); + var orders = []; + var not_loaded_count = 0; + + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id === this.pos_session.id) { + orders.push(new exports.Order({},{ + pos: this, + json: json, + })); + } else { + not_loaded_count += 1; + } + } + + if (not_loaded_count) { + console.info('There are '+not_loaded_count+' locally saved unpaid orders belonging to another session'); + } + + orders = orders.sort(function(a,b){ + return a.sequence_number - b.sequence_number; + }); + + if (orders.length) { + this.get('orders').add(orders); + } + }, + + /** + * Directly calls the requested service, instead of triggering a + * 'call_service' event up, which wouldn't work as services have no parent + * + * @param {OdooEvent} ev + */ + _trigger_up: function (ev) { + if (ev.is_stopped()) { + return; + } + const payload = ev.data; + if (ev.name === 'call_service') { + let args = payload.args || []; + if (payload.service === 'ajax' && payload.method === 'rpc') { + // ajax service uses an extra 'target' argument for rpc + args = args.concat(ev.target); + } + const service = this.env.services[payload.service]; + const result = service[payload.method].apply(service, args); + payload.callback(result); + } + }, + + electronic_payment_interfaces: {}, + + format_currency: function(amount, precision) { + var currency = + this && this.currency + ? this.currency + : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + + amount = this.format_currency_no_symbol(amount, precision, currency); + + if (currency.position === 'after') { + return amount + ' ' + (currency.symbol || ''); + } else { + return (currency.symbol || '') + ' ' + amount; + } + }, + + format_currency_no_symbol: function(amount, precision, currency) { + if (!currency) { + currency = + this && this.currency + ? this.currency + : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + } + var decimals = currency.decimals; + + if (precision && this.dp[precision] !== undefined) { + decimals = this.dp[precision]; + } + + if (typeof amount === 'number') { + amount = round_di(amount, decimals).toFixed(decimals); + amount = field_utils.format.float(round_di(amount, decimals), { + digits: [69, decimals], + }); + } + + return amount; + }, + + format_pr: function(value, precision) { + var decimals = + precision > 0 + ? Math.max(0, Math.ceil(Math.log(1.0 / precision) / Math.log(10))) + : 0; + return value.toFixed(decimals); + }, + + /** + * (value = 1.0000, decimals = 2) => '1' + * (value = 1.1234, decimals = 2) => '1.12' + * @param {number} value amount to format + */ + formatFixed: function(value) { + const currency = this.currency || { decimals: 2 }; + return `${Number(value.toFixed(currency.decimals || 0))}`; + }, + + disallowLineQuantityChange() { + return false; + }, + + getCurrencySymbol() { + return this.currency ? this.currency.symbol : '$'; + }, +}); + +/** + * Call this function to map your PaymentInterface implementation to + * the use_payment_terminal field. When the POS loads it will take + * care of instantiating your interface and setting it on the right + * payment methods. + * + * @param {string} use_payment_terminal - value used in the + * use_payment_terminal selection field + * + * @param {Object} ImplementedPaymentInterface - implemented + * PaymentInterface + */ +exports.register_payment_method = function(use_payment_terminal, ImplementedPaymentInterface) { + exports.PosModel.prototype.electronic_payment_interfaces[use_payment_terminal] = ImplementedPaymentInterface; +}; + +// Add fields to the list of read fields when a model is loaded +// by the point of sale. +// e.g: module.load_fields("product.product",['price','category']) + +exports.load_fields = function(model_name, fields) { + if (!(fields instanceof Array)) { + fields = [fields]; + } + + var models = exports.PosModel.prototype.models; + for (var i = 0; i < models.length; i++) { + var model = models[i]; + if (model.model === model_name) { + // if 'fields' is empty all fields are loaded, so we do not need + // to modify the array + if ((model.fields instanceof Array) && model.fields.length > 0) { + model.fields = model.fields.concat(fields || []); + } + } + } +}; + +// Loads openerp models at the point of sale startup. +// load_models take an array of model loader declarations. +// - The models will be loaded in the array order. +// - If no openerp model name is provided, no server data +// will be loaded, but the system can be used to preprocess +// data before load. +// - loader arguments can be functions that return a dynamic +// value. The function takes the PosModel as the first argument +// and a temporary object that is shared by all models, and can +// be used to store transient information between model loads. +// - There is no dependency management. The models must be loaded +// in the right order. Newly added models are loaded at the end +// but the after / before options can be used to load directly +// before / after another model. +// +// models: [{ +// model: [string] the name of the openerp model to load. +// label: [string] The label displayed during load. +// fields: [[string]|function] the list of fields to be loaded. +// Empty Array / Null loads all fields. +// order: [[string]|function] the models will be ordered by +// the provided fields +// domain: [domain|function] the domain that determines what +// models need to be loaded. Null loads everything +// ids: [[id]|function] the id list of the models that must +// be loaded. Overrides domain. +// context: [Dict|function] the openerp context for the model read +// condition: [function] do not load the models if it evaluates to +// false. +// loaded: [function(self,model)] this function is called once the +// models have been loaded, with the data as second argument +// if the function returns a promise, the next model will +// wait until it resolves before loading. +// }] +// +// options: +// before: [string] The model will be loaded before the named models +// (applies to both model name and label) +// after: [string] The model will be loaded after the (last loaded) +// named model. (applies to both model name and label) +// +exports.load_models = function(models,options) { + options = options || {}; + if (!(models instanceof Array)) { + models = [models]; + } + + var pmodels = exports.PosModel.prototype.models; + var index = pmodels.length; + if (options.before) { + for (var i = 0; i < pmodels.length; i++) { + if ( pmodels[i].model === options.before || + pmodels[i].label === options.before ){ + index = i; + break; + } + } + } else if (options.after) { + for (var i = 0; i < pmodels.length; i++) { + if ( pmodels[i].model === options.after || + pmodels[i].label === options.after ){ + index = i + 1; + } + } + } + pmodels.splice.apply(pmodels,[index,0].concat(models)); +}; + +exports.Product = Backbone.Model.extend({ + initialize: function(attr, options){ + _.extend(this, options); + }, + isAllowOnlyOneLot: function() { + const productUnit = this.get_unit(); + return this.tracking === 'lot' || !productUnit || !productUnit.is_pos_groupable; + }, + get_unit: function() { + var unit_id = this.uom_id; + if(!unit_id){ + return undefined; + } + unit_id = unit_id[0]; + if(!this.pos){ + return undefined; + } + return this.pos.units_by_id[unit_id]; + }, + // Port of get_product_price on product.pricelist. + // + // Anything related to UOM can be ignored, the POS will always use + // the default UOM set on the product and the user cannot change + // it. + // + // Pricelist items do not have to be sorted. All + // product.pricelist.item records are loaded with a search_read + // and were automatically sorted based on their _order by the + // ORM. After that they are added in this order to the pricelists. + get_price: function(pricelist, quantity, price_extra){ + var self = this; + var date = moment().startOf('day'); + + // In case of nested pricelists, it is necessary that all pricelists are made available in + // the POS. Display a basic alert to the user in this case. + if (pricelist === undefined) { + alert(_t( + 'An error occurred when loading product prices. ' + + 'Make sure all pricelists are available in the POS.' + )); + } + + var category_ids = []; + var category = this.categ; + while (category) { + category_ids.push(category.id); + category = category.parent; + } + + var pricelist_items = _.filter(pricelist.items, function (item) { + return (! item.product_tmpl_id || item.product_tmpl_id[0] === self.product_tmpl_id) && + (! item.product_id || item.product_id[0] === self.id) && + (! item.categ_id || _.contains(category_ids, item.categ_id[0])) && + (! item.date_start || moment(item.date_start).isSameOrBefore(date)) && + (! item.date_end || moment(item.date_end).isSameOrAfter(date)); + }); + + var price = self.lst_price; + if (price_extra){ + price += price_extra; + } + _.find(pricelist_items, function (rule) { + if (rule.min_quantity && quantity < rule.min_quantity) { + return false; + } + + if (rule.base === 'pricelist') { + price = self.get_price(rule.base_pricelist, quantity); + } else if (rule.base === 'standard_price') { + price = self.standard_price; + } + + if (rule.compute_price === 'fixed') { + price = rule.fixed_price; + return true; + } else if (rule.compute_price === 'percentage') { + price = price - (price * (rule.percent_price / 100)); + return true; + } else { + var price_limit = price; + price = price - (price * (rule.price_discount / 100)); + if (rule.price_round) { + price = round_pr(price, rule.price_round); + } + if (rule.price_surcharge) { + price += rule.price_surcharge; + } + if (rule.price_min_margin) { + price = Math.max(price, price_limit + rule.price_min_margin); + } + if (rule.price_max_margin) { + price = Math.min(price, price_limit + rule.price_max_margin); + } + return true; + } + + return false; + }); + + // This return value has to be rounded with round_di before + // being used further. Note that this cannot happen here, + // because it would cause inconsistencies with the backend for + // pricelist that have base == 'pricelist'. + return price; + }, +}); + +var orderline_id = 1; + +// An orderline represent one element of the content of a client's shopping cart. +// An orderline contains a product, its quantity, its price, discount. etc. +// An Order contains zero or more Orderlines. +exports.Orderline = Backbone.Model.extend({ + initialize: function(attr,options){ + this.pos = options.pos; + this.order = options.order; + if (options.json) { + try { + this.init_from_JSON(options.json); + } catch(error) { + console.error('ERROR: attempting to recover product ID', options.json.product_id, + 'not available in the point of sale. Correct the product or clean the browser cache.'); + } + return; + } + this.product = options.product; + this.set_product_lot(this.product); + this.set_quantity(1); + this.discount = 0; + this.discountStr = '0'; + this.selected = false; + this.description = ''; + this.price_extra = 0; + this.full_product_name = ''; + this.id = orderline_id++; + this.price_manually_set = false; + + if (options.price) { + this.set_unit_price(options.price); + } else { + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity())); + } + }, + init_from_JSON: function(json) { + this.product = this.pos.db.get_product_by_id(json.product_id); + this.set_product_lot(this.product); + this.price = json.price_unit; + this.set_discount(json.discount); + this.set_quantity(json.qty, 'do not recompute unit price'); + this.set_description(json.description); + this.set_price_extra(json.price_extra); + this.set_full_product_name(json.full_product_name); + this.id = json.id ? json.id : orderline_id++; + orderline_id = Math.max(this.id+1,orderline_id); + var pack_lot_lines = json.pack_lot_ids; + for (var i = 0; i < pack_lot_lines.length; i++) { + var packlotline = pack_lot_lines[i][2]; + var pack_lot_line = new exports.Packlotline({}, {'json': _.extend(packlotline, {'order_line':this})}); + this.pack_lot_lines.add(pack_lot_line); + } + }, + clone: function(){ + var orderline = new exports.Orderline({},{ + pos: this.pos, + order: this.order, + product: this.product, + price: this.price, + }); + orderline.order = null; + orderline.quantity = this.quantity; + orderline.quantityStr = this.quantityStr; + orderline.discount = this.discount; + orderline.price = this.price; + orderline.selected = false; + orderline.price_manually_set = this.price_manually_set; + return orderline; + }, + getPackLotLinesToEdit: function(isAllowOnlyOneLot) { + const currentPackLotLines = this.pack_lot_lines.models; + let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length; + nExtraLines = nExtraLines > 0 ? nExtraLines : 1; + const tempLines = currentPackLotLines + .map(lotLine => ({ + id: lotLine.cid, + text: lotLine.get('lot_name'), + })) + .concat( + Array.from(Array(nExtraLines)).map(_ => ({ + text: '', + })) + ); + return isAllowOnlyOneLot ? [tempLines[0]] : tempLines; + }, + /** + * @param { modifiedPackLotLines, newPackLotLines } + * @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name) + * @param {Array} newPackLotLines array of { lot_name: String } + */ + setPackLotLines: function({ modifiedPackLotLines, newPackLotLines }) { + // Set the new values for modified lot lines. + let lotLinesToRemove = []; + for (let lotLine of this.pack_lot_lines.models) { + const modifiedLotName = modifiedPackLotLines[lotLine.cid]; + if (modifiedLotName) { + lotLine.set({ lot_name: modifiedLotName }); + } else { + // We should not call lotLine.remove() here because + // we don't want to mutate the array while looping thru it. + lotLinesToRemove.push(lotLine); + } + } + + // Remove those that needed to be removed. + for (let lotLine of lotLinesToRemove) { + lotLine.remove(); + } + + // Create new pack lot lines. + let newPackLotLine; + for (let newLotLine of newPackLotLines) { + newPackLotLine = new exports.Packlotline({}, { order_line: this }); + newPackLotLine.set({ lot_name: newLotLine.lot_name }); + this.pack_lot_lines.add(newPackLotLine); + } + + // Set the quantity of the line based on number of pack lots. + this.pack_lot_lines.set_quantity_by_lot(); + }, + set_product_lot: function(product){ + this.has_product_lot = product.tracking !== 'none'; + this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null, {'order_line': this}); + }, + // sets a discount [0,100]% + set_discount: function(discount){ + var parsed_discount = isNaN(parseFloat(discount)) ? 0 : field_utils.parse.float('' + discount); + var disc = Math.min(Math.max(parsed_discount || 0, 0),100); + this.discount = disc; + this.discountStr = '' + disc; + this.trigger('change',this); + }, + // returns the discount [0,100]% + get_discount: function(){ + return this.discount; + }, + get_discount_str: function(){ + return this.discountStr; + }, + set_description: function(description){ + this.description = description || ''; + }, + set_price_extra: function(price_extra){ + this.price_extra = parseFloat(price_extra) || 0.0; + }, + set_full_product_name: function(full_product_name){ + this.full_product_name = full_product_name || ''; + }, + get_price_extra: function () { + return this.price_extra; + }, + // sets the quantity of the product. The quantity will be rounded according to the + // product's unity of measure properties. Quantities greater than zero will not get + // rounded to zero + set_quantity: function(quantity, keep_price){ + this.order.assert_editable(); + if(quantity === 'remove'){ + this.order.remove_orderline(this); + return; + }else{ + var quant = typeof(quantity) === 'number' ? quantity : (field_utils.parse.float('' + quantity) || 0); + var unit = this.get_unit(); + if(unit){ + if (unit.rounding) { + var decimals = this.pos.dp['Product Unit of Measure']; + var rounding = Math.max(unit.rounding, Math.pow(10, -decimals)); + this.quantity = round_pr(quant, rounding); + this.quantityStr = field_utils.format.float(this.quantity, {digits: [69, decimals]}); + } else { + this.quantity = round_pr(quant, 1); + this.quantityStr = this.quantity.toFixed(0); + } + }else{ + this.quantity = quant; + this.quantityStr = '' + this.quantity; + } + } + + // just like in sale.order changing the quantity will recompute the unit price + if(! keep_price && ! this.price_manually_set){ + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity(), this.get_price_extra())); + this.order.fix_tax_included_price(this); + } + this.trigger('change', this); + }, + // return the quantity of product + get_quantity: function(){ + return this.quantity; + }, + get_quantity_str: function(){ + return this.quantityStr; + }, + get_quantity_str_with_unit: function(){ + var unit = this.get_unit(); + if(unit && !unit.is_pos_groupable){ + return this.quantityStr + ' ' + unit.name; + }else{ + return this.quantityStr; + } + }, + + get_lot_lines: function() { + return this.pack_lot_lines.models; + }, + + get_required_number_of_lots: function(){ + var lots_required = 1; + + if (this.product.tracking == 'serial') { + lots_required = Math.abs(this.quantity); + } + + return lots_required; + }, + + has_valid_product_lot: function(){ + if(!this.has_product_lot){ + return true; + } + var valid_product_lot = this.pack_lot_lines.get_valid_lots(); + return this.get_required_number_of_lots() === valid_product_lot.length; + }, + + // return the unit of measure of the product + get_unit: function(){ + return this.product.get_unit(); + }, + // return the product of this orderline + get_product: function(){ + return this.product; + }, + get_full_product_name: function () { + if (this.full_product_name) { + return this.full_product_name + } + var full_name = this.product.display_name; + if (this.description) { + full_name += ` (${this.description})`; + } + return full_name; + }, + // selects or deselects this orderline + set_selected: function(selected){ + this.selected = selected; + // this trigger also triggers the change event of the collection. + this.trigger('change',this); + this.trigger('new-orderline-selected'); + }, + // returns true if this orderline is selected + is_selected: function(){ + return this.selected; + }, + // when we add an new orderline we want to merge it with the last line to see reduce the number of items + // in the orderline. This returns true if it makes sense to merge the two + can_be_merged_with: function(orderline){ + var price = parseFloat(round_di(this.price || 0, this.pos.dp['Product Price']).toFixed(this.pos.dp['Product Price'])); + var order_line_price = orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity()); + order_line_price = orderline.compute_fixed_price(order_line_price); + if( this.get_product().id !== orderline.get_product().id){ //only orderline of the same product can be merged + return false; + }else if(!this.get_unit() || !this.get_unit().is_pos_groupable){ + return false; + }else if(this.get_discount() > 0){ // we don't merge discounted orderlines + return false; + }else if(!utils.float_is_zero(price - order_line_price - orderline.get_price_extra(), + this.pos.currency.decimals)){ + return false; + }else if(this.product.tracking == 'lot' && (this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)) { + return false; + }else if (this.description !== orderline.description) { + return false; + }else{ + return true; + } + }, + merge: function(orderline){ + this.order.assert_editable(); + this.set_quantity(this.get_quantity() + orderline.get_quantity()); + }, + export_as_JSON: function() { + var pack_lot_ids = []; + if (this.has_product_lot){ + this.pack_lot_lines.each(_.bind( function(item) { + return pack_lot_ids.push([0, 0, item.export_as_JSON()]); + }, this)); + } + return { + qty: this.get_quantity(), + price_unit: this.get_unit_price(), + price_subtotal: this.get_price_without_tax(), + price_subtotal_incl: this.get_price_with_tax(), + discount: this.get_discount(), + product_id: this.get_product().id, + tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function(tax){ return tax.id; })]], + id: this.id, + pack_lot_ids: pack_lot_ids, + description: this.description, + full_product_name: this.get_full_product_name(), + price_extra: this.get_price_extra(), + }; + }, + //used to create a json of the ticket, to be sent to the printer + export_for_printing: function(){ + return { + id: this.id, + quantity: this.get_quantity(), + unit_name: this.get_unit().name, + is_in_unit: this.get_unit().id == this.pos.uom_unit_id, + price: this.get_unit_display_price(), + discount: this.get_discount(), + product_name: this.get_product().display_name, + product_name_wrapped: this.generate_wrapped_product_name(), + price_lst: this.get_lst_price(), + display_discount_policy: this.display_discount_policy(), + price_display_one: this.get_display_price_one(), + price_display : this.get_display_price(), + price_with_tax : this.get_price_with_tax(), + price_without_tax: this.get_price_without_tax(), + price_with_tax_before_discount: this.get_price_with_tax_before_discount(), + tax: this.get_tax(), + product_description: this.get_product().description, + product_description_sale: this.get_product().description_sale, + pack_lot_lines: this.get_lot_lines() + }; + }, + generate_wrapped_product_name: function() { + var MAX_LENGTH = 24; // 40 * line ratio of .6 + var wrapped = []; + var name = this.get_full_product_name(); + var current_line = ""; + + while (name.length > 0) { + var space_index = name.indexOf(" "); + + if (space_index === -1) { + space_index = name.length; + } + + if (current_line.length + space_index > MAX_LENGTH) { + if (current_line.length) { + wrapped.push(current_line); + } + current_line = ""; + } + + current_line += name.slice(0, space_index + 1); + name = name.slice(space_index + 1); + } + + if (current_line.length) { + wrapped.push(current_line); + } + + return wrapped; + }, + // changes the base price of the product for this orderline + set_unit_price: function(price){ + this.order.assert_editable(); + var parsed_price = !isNaN(price) ? + price : + isNaN(parseFloat(price)) ? 0 : field_utils.parse.float('' + price) + this.price = round_di(parsed_price || 0, this.pos.dp['Product Price']); + this.trigger('change',this); + }, + get_unit_price: function(){ + var digits = this.pos.dp['Product Price']; + // round and truncate to mimic _symbol_set behavior + return parseFloat(round_di(this.price || 0, digits).toFixed(digits)); + }, + get_unit_display_price: function(){ + if (this.pos.config.iface_tax_included === 'total') { + var quantity = this.quantity; + this.quantity = 1.0; + var price = this.get_all_prices().priceWithTax; + this.quantity = quantity; + return price; + } else { + return this.get_unit_price(); + } + }, + get_base_price: function(){ + var rounding = this.pos.currency.rounding; + return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount()/100), rounding); + }, + get_display_price_one: function(){ + var rounding = this.pos.currency.rounding; + var price_unit = this.get_unit_price(); + if (this.pos.config.iface_tax_included !== 'total') { + return round_pr(price_unit * (1.0 - (this.get_discount() / 100.0)), rounding); + } else { + var product = this.get_product(); + var taxes_ids = product.taxes_id; + var taxes = this.pos.taxes; + var product_taxes = []; + + _(taxes_ids).each(function(el){ + product_taxes.push(_.detect(taxes, function(t){ + return t.id === el; + })); + }); + + var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding); + + return round_pr(all_taxes.total_included * (1 - this.get_discount()/100), rounding); + } + }, + get_display_price: function(){ + if (this.pos.config.iface_tax_included === 'total') { + return this.get_price_with_tax(); + } else { + return this.get_base_price(); + } + }, + get_price_without_tax: function(){ + return this.get_all_prices().priceWithoutTax; + }, + get_price_with_tax: function(){ + return this.get_all_prices().priceWithTax; + }, + get_price_with_tax_before_discount: function () { + return this.get_all_prices().priceWithTaxBeforeDiscount; + }, + get_tax: function(){ + return this.get_all_prices().tax; + }, + get_applicable_taxes: function(){ + var i; + // Shenaningans because we need + // to keep the taxes ordering. + var ptaxes_ids = this.get_product().taxes_id; + var ptaxes_set = {}; + for (i = 0; i < ptaxes_ids.length; i++) { + ptaxes_set[ptaxes_ids[i]] = true; + } + var taxes = []; + for (i = 0; i < this.pos.taxes.length; i++) { + if (ptaxes_set[this.pos.taxes[i].id]) { + taxes.push(this.pos.taxes[i]); + } + } + return taxes; + }, + get_tax_details: function(){ + return this.get_all_prices().taxDetails; + }, + get_taxes: function(){ + var taxes_ids = this.get_product().taxes_id; + var taxes = []; + for (var i = 0; i < taxes_ids.length; i++) { + if (this.pos.taxes_by_id[taxes_ids[i]]) { + taxes.push(this.pos.taxes_by_id[taxes_ids[i]]); + } + } + return taxes; + }, + _map_tax_fiscal_position: function(tax, order = false) { + var self = this; + var current_order = order || this.pos.get_order(); + var order_fiscal_position = current_order && current_order.fiscal_position; + var taxes = []; + + if (order_fiscal_position) { + var tax_mappings = _.filter(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) { + return fiscal_position_tax.tax_src_id[0] === tax.id; + }); + + if (tax_mappings && tax_mappings.length) { + _.each(tax_mappings, function(tm) { + if (tm.tax_dest_id) { + taxes.push(self.pos.taxes_by_id[tm.tax_dest_id[0]]); + } + }); + } else{ + taxes.push(tax); + } + } else { + taxes.push(tax); + } + + return taxes; + }, + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function(tax, base_amount, quantity, price_exclude) { + if(price_exclude === undefined) + var price_include = tax.price_include; + else + var price_include = !price_exclude; + if (tax.amount_type === 'fixed') { + var sign_base_amount = Math.sign(base_amount) || 1; + // Since base amount has been computed with quantity + // we take the abs of quantity + // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce + return tax.amount * sign_base_amount * Math.abs(quantity); + } + if (tax.amount_type === 'percent' && !price_include){ + return base_amount * tax.amount / 100; + } + if (tax.amount_type === 'percent' && price_include){ + return base_amount - (base_amount / (1 + tax.amount / 100)); + } + if (tax.amount_type === 'division' && !price_include) { + return base_amount / (1 - tax.amount / 100) - base_amount; + } + if (tax.amount_type === 'division' && price_include) { + return base_amount - (base_amount * (tax.amount / 100)); + } + return false; + }, + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ + compute_all: function(taxes, price_unit, quantity, currency_rounding, handle_price_include=true) { + var self = this; + + // 1) Flatten the taxes. + + var _collect_taxes = function(taxes, all_taxes){ + taxes.sort(function (tax1, tax2) { + return tax1.sequence - tax2.sequence; + }); + _(taxes).each(function(tax){ + if(tax.amount_type === 'group') + all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); + else + all_taxes.push(tax); + }); + return all_taxes; + } + var collect_taxes = function(taxes){ + return _collect_taxes(taxes, []); + } + + taxes = collect_taxes(taxes); + + // 2) Deal with the rounding methods + + var round_tax = this.pos.company.tax_calculation_rounding_method != 'round_globally'; + + var initial_currency_rounding = currency_rounding; + if(!round_tax) + currency_rounding = currency_rounding * 0.00001; + + // 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + var recompute_base = function(base_amount, fixed_amount, percent_amount, division_amount){ + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100; + } + + var base = round_pr(price_unit * quantity, initial_currency_rounding); + + var sign = 1; + if(base < 0){ + base = -base; + sign = -1; + } + + var total_included_checkpoints = {}; + var i = taxes.length - 1; + var store_included_tax_total = true; + + var incl_fixed_amount = 0.0; + var incl_percent_amount = 0.0; + var incl_division_amount = 0.0; + + var cached_tax_amounts = {}; + if (handle_price_include){ + _(taxes.reverse()).each(function(tax){ + if(tax.include_base_amount){ + base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount); + incl_fixed_amount = 0.0; + incl_percent_amount = 0.0; + incl_division_amount = 0.0; + store_included_tax_total = true; + } + if(tax.price_include){ + if(tax.amount_type === 'percent') + incl_percent_amount += tax.amount; + else if(tax.amount_type === 'division') + incl_division_amount += tax.amount; + else if(tax.amount_type === 'fixed') + incl_fixed_amount += Math.abs(quantity) * tax.amount + else{ + var tax_amount = self._compute_all(tax, base, quantity); + incl_fixed_amount += tax_amount; + cached_tax_amounts[i] = tax_amount; + } + if(store_included_tax_total){ + total_included_checkpoints[i] = base; + store_included_tax_total = false; + } + } + i -= 1; + }); + } + + var total_excluded = round_pr(recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount), initial_currency_rounding); + var total_included = total_excluded; + + // 4) Iterate the taxes in the sequence order to fill missing base/amount values. + + base = total_excluded; + + var skip_checkpoint = false; + + var taxes_vals = []; + i = 0; + var cumulated_tax_included_amount = 0; + _(taxes.reverse()).each(function(tax){ + if(!skip_checkpoint && tax.price_include && total_included_checkpoints[i] !== undefined){ + var tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount); + cumulated_tax_included_amount = 0; + }else + var tax_amount = self._compute_all(tax, base, quantity, true); + + tax_amount = round_pr(tax_amount, currency_rounding); + + if(tax.price_include && total_included_checkpoints[i] === undefined) + cumulated_tax_included_amount += tax_amount; + + taxes_vals.push({ + 'id': tax.id, + 'name': tax.name, + 'amount': sign * tax_amount, + 'base': sign * round_pr(base, currency_rounding), + }); + + if(tax.include_base_amount){ + base += tax_amount; + if(!tax.price_include) + skip_checkpoint = true; + } + + total_included += tax_amount; + i += 1; + }); + + return { + 'taxes': taxes_vals, + 'total_excluded': sign * round_pr(total_excluded, this.pos.currency.rounding), + 'total_included': sign * round_pr(total_included, this.pos.currency.rounding), + } + }, + get_all_prices: function(){ + var self = this; + + var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); + var taxtotal = 0; + + var product = this.get_product(); + var taxes = this.pos.taxes; + var taxes_ids = _.filter(product.taxes_id, t => t in this.pos.taxes_by_id); + var taxdetail = {}; + var product_taxes = []; + + _(taxes_ids).each(function(el){ + var tax = _.detect(taxes, function(t){ + return t.id === el; + }); + product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax, self.order)); + }); + product_taxes = _.uniq(product_taxes, function(tax) { return tax.id; }); + + var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding); + var all_taxes_before_discount = this.compute_all(product_taxes, this.get_unit_price(), this.get_quantity(), this.pos.currency.rounding); + _(all_taxes.taxes).each(function(tax) { + taxtotal += tax.amount; + taxdetail[tax.id] = tax.amount; + }); + + return { + "priceWithTax": all_taxes.total_included, + "priceWithoutTax": all_taxes.total_excluded, + "priceSumTaxVoid": all_taxes.total_void, + "priceWithTaxBeforeDiscount": all_taxes_before_discount.total_included, + "tax": taxtotal, + "taxDetails": taxdetail, + }; + }, + display_discount_policy: function(){ + return this.order.pricelist.discount_policy; + }, + compute_fixed_price: function (price) { + var order = this.order; + if(order.fiscal_position) { + var taxes = this.get_taxes(); + var mapped_included_taxes = []; + var new_included_taxes = []; + var self = this; + _(taxes).each(function(tax) { + var line_taxes = self._map_tax_fiscal_position(tax, order); + if (line_taxes.length && line_taxes[0].price_include){ + new_included_taxes = new_included_taxes.concat(line_taxes); + } + if(tax.price_include && !_.contains(line_taxes, tax)){ + mapped_included_taxes.push(tax); + } + }); + + if (mapped_included_taxes.length > 0) { + if (new_included_taxes.length > 0) { + var price_without_taxes = this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded + return this.compute_all(new_included_taxes, price_without_taxes, 1, order.pos.currency.rounding, false).total_included + } + else{ + return this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded; + } + } + } + return price; + }, + get_fixed_lst_price: function(){ + return this.compute_fixed_price(this.get_lst_price()); + }, + get_lst_price: function(){ + return this.product.lst_price; + }, + set_lst_price: function(price){ + this.order.assert_editable(); + this.product.lst_price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']); + this.trigger('change',this); + }, + is_last_line: function() { + var order = this.pos.get_order(); + var last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length-1]; + var selectedLine = order? order.selected_orderline: null; + + return !selectedLine ? false : last_id === selectedLine.cid; + }, +}); + +var OrderlineCollection = Backbone.Collection.extend({ + model: exports.Orderline, +}); + +exports.Packlotline = Backbone.Model.extend({ + defaults: { + lot_name: null + }, + initialize: function(attributes, options){ + this.order_line = options.order_line; + if (options.json) { + this.init_from_JSON(options.json); + return; + } + }, + + init_from_JSON: function(json) { + this.order_line = json.order_line; + this.set_lot_name(json.lot_name); + }, + + set_lot_name: function(name){ + this.set({lot_name : _.str.trim(name) || null}); + }, + + get_lot_name: function(){ + return this.get('lot_name'); + }, + + export_as_JSON: function(){ + return { + lot_name: this.get_lot_name(), + }; + }, + + add: function(){ + var order_line = this.order_line, + index = this.collection.indexOf(this); + var new_lot_model = new exports.Packlotline({}, {'order_line': this.order_line}); + this.collection.add(new_lot_model, {at: index + 1}); + return new_lot_model; + }, + + remove: function(){ + this.collection.remove(this); + } +}); + +var PacklotlineCollection = Backbone.Collection.extend({ + model: exports.Packlotline, + initialize: function(models, options) { + this.order_line = options.order_line; + }, + + get_valid_lots: function(){ + return this.filter(function(model){ + return model.get('lot_name'); + }); + }, + + set_quantity_by_lot: function() { + var valid_lots_quantity = this.get_valid_lots().length; + if (this.order_line.quantity < 0){ + valid_lots_quantity = -valid_lots_quantity; + } + this.order_line.set_quantity(valid_lots_quantity); + } +}); + +// Every Paymentline contains a cashregister and an amount of money. +exports.Paymentline = Backbone.Model.extend({ + initialize: function(attributes, options) { + this.pos = options.pos; + this.order = options.order; + this.amount = 0; + this.selected = false; + this.cashier_receipt = ''; + this.ticket = ''; + this.payment_status = ''; + this.card_type = ''; + this.cardholder_name = ''; + this.transaction_id = ''; + + if (options.json) { + this.init_from_JSON(options.json); + return; + } + this.payment_method = options.payment_method; + if (this.payment_method === undefined) { + throw new Error(_t('Please configure a payment method in your POS.')); + } + this.name = this.payment_method.name; + }, + init_from_JSON: function(json){ + this.amount = json.amount; + this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id]; + this.name = this.payment_method.name; + this.payment_status = json.payment_status; + this.ticket = json.ticket; + this.card_type = json.card_type; + this.cardholder_name = json.cardholder_name; + this.transaction_id = json.transaction_id; + this.is_change = json.is_change; + }, + //sets the amount of money on this payment line + set_amount: function(value){ + this.order.assert_editable(); + this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals); + if (this.pos.config.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display(); + this.trigger('change',this); + }, + // returns the amount of money on this paymentline + get_amount: function(){ + return this.amount; + }, + get_amount_str: function(){ + return field_utils.format.float(this.amount, {digits: [69, this.pos.currency.decimals]}); + }, + set_selected: function(selected){ + if(this.selected !== selected){ + this.selected = selected; + this.trigger('change',this); + } + }, + /** + * returns {string} payment status. + */ + get_payment_status: function() { + return this.payment_status; + }, + + /** + * Set the new payment status. + * + * @param {string} value - new status. + */ + set_payment_status: function(value) { + this.payment_status = value; + this.trigger('change', this); + }, + + /** + * Check if paymentline is done. + * Paymentline is done if there is no payment status or the payment status is done. + */ + is_done: function() { + return this.get_payment_status() ? this.get_payment_status() === 'done' || this.get_payment_status() === 'reversed': true; + }, + + /** + * Set info to be printed on the cashier receipt. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_cashier_receipt: function (value) { + this.cashier_receipt = value; + this.trigger('change', this); + }, + + /** + * Set additional info to be printed on the receipts. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_receipt_info: function(value) { + this.ticket += value; + this.trigger('change', this); + }, + + // returns the associated cashregister + //exports as JSON for server communication + export_as_JSON: function(){ + return { + name: time.datetime_to_str(new Date()), + payment_method_id: this.payment_method.id, + amount: this.get_amount(), + payment_status: this.payment_status, + ticket: this.ticket, + card_type: this.card_type, + cardholder_name: this.cardholder_name, + transaction_id: this.transaction_id, + }; + }, + //exports as JSON for receipt printing + export_for_printing: function(){ + return { + cid: this.cid, + amount: this.get_amount(), + name: this.name, + ticket: this.ticket, + }; + }, + // If payment status is a non-empty string, then it is an electronic payment. + // TODO: There has to be a less confusing way to distinguish simple payments + // from electronic transactions. Perhaps use a flag? + is_electronic: function() { + return Boolean(this.get_payment_status()); + }, +}); + +var PaymentlineCollection = Backbone.Collection.extend({ + model: exports.Paymentline, +}); + +// An order more or less represents the content of a client's shopping cart (the OrderLines) +// plus the associated payment information (the Paymentlines) +// there is always an active ('selected') order in the Pos, a new one is created +// automaticaly once an order is completed and sent to the server. +exports.Order = Backbone.Model.extend({ + initialize: function(attributes,options){ + Backbone.Model.prototype.initialize.apply(this, arguments); + var self = this; + options = options || {}; + + this.locked = false; + this.pos = options.pos; + this.selected_orderline = undefined; + this.selected_paymentline = undefined; + this.screen_data = {}; // see Gui + this.temporary = options.temporary || false; + this.creation_date = new Date(); + this.to_invoice = false; + this.orderlines = new OrderlineCollection(); + this.paymentlines = new PaymentlineCollection(); + this.pos_session_id = this.pos.pos_session.id; + this.employee = this.pos.employee; + this.finalized = false; // if true, cannot be modified. + this.set_pricelist(this.pos.default_pricelist); + + this.set({ client: null }); + + this.uiState = { + ReceiptScreen: new Context({ + inputEmail: '', + // if null: not yet tried to send + // if false/true: tried sending email + emailSuccessful: null, + emailNotice: '', + }), + TipScreen: new Context({ + inputTipAmount: '', + }) + }; + + if (options.json) { + this.init_from_JSON(options.json); + } else { + this.sequence_number = this.pos.pos_session.sequence_number++; + this.uid = this.generate_unique_id(); + this.name = _.str.sprintf(_t("Order %s"), this.uid); + this.validation_date = undefined; + this.fiscal_position = _.find(this.pos.fiscal_positions, function(fp) { + return fp.id === self.pos.config.default_fiscal_position_id[0]; + }); + } + + this.on('change', function(){ this.save_to_db("order:change"); }, this); + this.orderlines.on('change', function(){ this.save_to_db("orderline:change"); }, this); + this.orderlines.on('add', function(){ this.save_to_db("orderline:add"); }, this); + this.orderlines.on('remove', function(){ this.save_to_db("orderline:remove"); }, this); + this.paymentlines.on('change', function(){ this.save_to_db("paymentline:change"); }, this); + this.paymentlines.on('add', function(){ this.save_to_db("paymentline:add"); }, this); + this.paymentlines.on('remove', function(){ this.save_to_db("paymentline:rem"); }, this); + + if (this.pos.config.iface_customer_facing_display) { + this.paymentlines.on('add', this.pos.send_current_order_to_customer_facing_display, this.pos); + this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos); + } + + this.save_to_db(); + + return this; + }, + save_to_db: function(){ + if (!this.temporary && !this.locked) { + this.pos.db.save_unpaid_order(this); + } + }, + /** + * Initialize PoS order from a JSON string. + * + * If the order was created in another session, the sequence number should be changed so it doesn't conflict + * with orders in the current session. + * Else, the sequence number of the session should follow on the sequence number of the loaded order. + * + * @param {object} json JSON representing one PoS order. + */ + init_from_JSON: function(json) { + var client; + if (json.pos_session_id !== this.pos.pos_session.id) { + this.sequence_number = this.pos.pos_session.sequence_number++; + } else { + this.sequence_number = json.sequence_number; + this.pos.pos_session.sequence_number = Math.max(this.sequence_number+1,this.pos.pos_session.sequence_number); + } + this.session_id = this.pos.pos_session.id; + this.uid = json.uid; + this.name = _.str.sprintf(_t("Order %s"), this.uid); + this.validation_date = json.creation_date; + this.server_id = json.server_id ? json.server_id : false; + this.user_id = json.user_id; + + if (json.fiscal_position_id) { + var fiscal_position = _.find(this.pos.fiscal_positions, function (fp) { + return fp.id === json.fiscal_position_id; + }); + + if (fiscal_position) { + this.fiscal_position = fiscal_position; + } else { + console.error('ERROR: trying to load a fiscal position not available in the pos'); + } + } + + if (json.pricelist_id) { + this.pricelist = _.find(this.pos.pricelists, function (pricelist) { + return pricelist.id === json.pricelist_id; + }); + } else { + this.pricelist = this.pos.default_pricelist; + } + + if (json.partner_id) { + client = this.pos.db.get_partner_by_id(json.partner_id); + if (!client) { + console.error('ERROR: trying to load a partner not available in the pos'); + } + } else { + client = null; + } + this.set_client(client); + + this.temporary = false; // FIXME + this.to_invoice = false; // FIXME + + var orderlines = json.lines; + for (var i = 0; i < orderlines.length; i++) { + var orderline = orderlines[i][2]; + this.add_orderline(new exports.Orderline({}, {pos: this.pos, order: this, json: orderline})); + } + + var paymentlines = json.statement_ids; + for (var i = 0; i < paymentlines.length; i++) { + var paymentline = paymentlines[i][2]; + var newpaymentline = new exports.Paymentline({},{pos: this.pos, order: this, json: paymentline}); + this.paymentlines.add(newpaymentline); + + if (i === paymentlines.length - 1) { + this.select_paymentline(newpaymentline); + } + } + + // Tag this order as 'locked' if it is already paid. + this.locked = ['paid', 'done', 'invoiced'].includes(json.state); + this.state = json.state; + this.amount_return = json.amount_return; + this.account_move = json.account_move; + this.backendId = json.id; + this.isFromClosedSession = json.is_session_closed; + this.is_tipped = json.is_tipped || false; + this.tip_amount = json.tip_amount || 0; + }, + export_as_JSON: function() { + var orderLines, paymentLines; + orderLines = []; + this.orderlines.each(_.bind( function(item) { + return orderLines.push([0, 0, item.export_as_JSON()]); + }, this)); + paymentLines = []; + this.paymentlines.each(_.bind( function(item) { + return paymentLines.push([0, 0, item.export_as_JSON()]); + }, this)); + var json = { + name: this.get_name(), + amount_paid: this.get_total_paid() - this.get_change(), + amount_total: this.get_total_with_tax(), + amount_tax: this.get_total_tax(), + amount_return: this.get_change(), + lines: orderLines, + statement_ids: paymentLines, + pos_session_id: this.pos_session_id, + pricelist_id: this.pricelist ? this.pricelist.id : false, + partner_id: this.get_client() ? this.get_client().id : false, + user_id: this.pos.user.id, + uid: this.uid, + sequence_number: this.sequence_number, + creation_date: this.validation_date || this.creation_date, // todo: rename creation_date in master + fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false, + server_id: this.server_id ? this.server_id : false, + to_invoice: this.to_invoice ? this.to_invoice : false, + is_tipped: this.is_tipped || false, + tip_amount: this.tip_amount || 0, + }; + if (!this.is_paid && this.user_id) { + json.user_id = this.user_id; + } + return json; + }, + export_for_printing: function(){ + var orderlines = []; + var self = this; + + this.orderlines.each(function(orderline){ + orderlines.push(orderline.export_for_printing()); + }); + + // If order is locked (paid), the 'change' is saved as negative payment, + // and is flagged with is_change = true. A receipt that is printed first + // time doesn't show this negative payment so we filter it out. + var paymentlines = this.paymentlines.models + .filter(function (paymentline) { + return !paymentline.is_change; + }) + .map(function (paymentline) { + return paymentline.export_for_printing(); + }); + var client = this.get('client'); + var cashier = this.pos.get_cashier(); + var company = this.pos.company; + var date = new Date(); + + function is_html(subreceipt){ + return subreceipt ? (subreceipt.split('\n')[0].indexOf('<!DOCTYPE QWEB') >= 0) : false; + } + + function render_html(subreceipt){ + if (!is_html(subreceipt)) { + return subreceipt; + } else { + subreceipt = subreceipt.split('\n').slice(1).join('\n'); + var qweb = new QWeb2.Engine(); + qweb.debug = config.isDebug(); + qweb.default_dict = _.clone(QWeb.default_dict); + qweb.add_template('<templates><t t-name="subreceipt">'+subreceipt+'</t></templates>'); + + return qweb.render('subreceipt',{'pos':self.pos,'order':self, 'receipt': receipt}) ; + } + } + + var receipt = { + orderlines: orderlines, + paymentlines: paymentlines, + subtotal: this.get_subtotal(), + total_with_tax: this.get_total_with_tax(), + total_rounded: this.get_total_with_tax() + this.get_rounding_applied(), + total_without_tax: this.get_total_without_tax(), + total_tax: this.get_total_tax(), + total_paid: this.get_total_paid(), + total_discount: this.get_total_discount(), + rounding_applied: this.get_rounding_applied(), + tax_details: this.get_tax_details(), + change: this.locked ? this.amount_return : this.get_change(), + name : this.get_name(), + client: client ? client : null , + invoice_id: null, //TODO + cashier: cashier ? cashier.name : null, + precision: { + price: 2, + money: 2, + quantity: 3, + }, + date: { + year: date.getFullYear(), + month: date.getMonth(), + date: date.getDate(), // day of the month + day: date.getDay(), // day of the week + hour: date.getHours(), + minute: date.getMinutes() , + isostring: date.toISOString(), + localestring: this.formatted_validation_date, + }, + company:{ + email: company.email, + website: company.website, + company_registry: company.company_registry, + contact_address: company.partner_id[1], + vat: company.vat, + vat_label: company.country && company.country.vat_label || _t('Tax ID'), + name: company.name, + phone: company.phone, + logo: this.pos.company_logo_base64, + }, + currency: this.pos.currency, + }; + + if (is_html(this.pos.config.receipt_header)){ + receipt.header = ''; + receipt.header_html = render_html(this.pos.config.receipt_header); + } else { + receipt.header = this.pos.config.receipt_header || ''; + } + + if (is_html(this.pos.config.receipt_footer)){ + receipt.footer = ''; + receipt.footer_html = render_html(this.pos.config.receipt_footer); + } else { + receipt.footer = this.pos.config.receipt_footer || ''; + } + + return receipt; + }, + is_empty: function(){ + return this.orderlines.models.length === 0; + }, + generate_unique_id: function() { + // Generates a public identification number for the order. + // The generated number must be unique and sequential. They are made 12 digit long + // to fit into EAN-13 barcodes, should it be needed + + function zero_pad(num,size){ + var s = ""+num; + while (s.length < size) { + s = "0" + s; + } + return s; + } + return zero_pad(this.pos.pos_session.id,5) +'-'+ + zero_pad(this.pos.pos_session.login_number,3) +'-'+ + zero_pad(this.sequence_number,4); + }, + get_name: function() { + return this.name; + }, + assert_editable: function() { + if (this.finalized) { + throw new Error('Finalized Order cannot be modified'); + } + }, + /* ---- Order Lines --- */ + add_orderline: function(line){ + this.assert_editable(); + if(line.order){ + line.order.remove_orderline(line); + } + line.order = this; + this.orderlines.add(line); + this.select_orderline(this.get_last_orderline()); + }, + get_orderline: function(id){ + var orderlines = this.orderlines.models; + for(var i = 0; i < orderlines.length; i++){ + if(orderlines[i].id === id){ + return orderlines[i]; + } + } + return null; + }, + get_orderlines: function(){ + return this.orderlines.models; + }, + get_last_orderline: function(){ + return this.orderlines.at(this.orderlines.length -1); + }, + get_tip: function() { + var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); + var lines = this.get_orderlines(); + if (!tip_product) { + return 0; + } else { + for (var i = 0; i < lines.length; i++) { + if (lines[i].get_product() === tip_product) { + return lines[i].get_unit_price(); + } + } + return 0; + } + }, + + initialize_validation_date: function () { + this.validation_date = new Date(); + this.formatted_validation_date = field_utils.format.datetime( + moment(this.validation_date), {}, {timezone: false}); + }, + + set_tip: function(tip) { + var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); + var lines = this.get_orderlines(); + if (tip_product) { + for (var i = 0; i < lines.length; i++) { + if (lines[i].get_product() === tip_product) { + lines[i].set_unit_price(tip); + lines[i].set_lst_price(tip); + lines[i].price_manually_set = true; + lines[i].order.tip_amount = tip; + return; + } + } + return this.add_product(tip_product, { + is_tip: true, + quantity: 1, + price: tip, + lst_price: tip, + extras: {price_manually_set: true}, + }); + } + }, + set_pricelist: function (pricelist) { + var self = this; + this.pricelist = pricelist; + + var lines_to_recompute = _.filter(this.get_orderlines(), function (line) { + return ! line.price_manually_set; + }); + _.each(lines_to_recompute, function (line) { + line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity(), line.get_price_extra())); + self.fix_tax_included_price(line); + }); + this.trigger('change'); + }, + remove_orderline: function( line ){ + this.assert_editable(); + this.orderlines.remove(line); + this.select_orderline(this.get_last_orderline()); + }, + + fix_tax_included_price: function(line){ + line.set_unit_price(line.compute_fixed_price(line.price)); + }, + + add_product: function(product, options){ + if(this._printed){ + this.destroy(); + return this.pos.get_order().add_product(product, options); + } + this.assert_editable(); + options = options || {}; + var line = new exports.Orderline({}, {pos: this.pos, order: this, product: product}); + this.fix_tax_included_price(line); + + if(options.quantity !== undefined){ + line.set_quantity(options.quantity); + } + + if (options.price_extra !== undefined){ + line.price_extra = options.price_extra; + line.set_unit_price(line.product.get_price(this.pricelist, line.get_quantity(), options.price_extra)); + this.fix_tax_included_price(line); + } + + if(options.price !== undefined){ + line.set_unit_price(options.price); + this.fix_tax_included_price(line); + } + + if(options.lst_price !== undefined){ + line.set_lst_price(options.lst_price); + } + + if(options.discount !== undefined){ + line.set_discount(options.discount); + } + + if (options.description !== undefined){ + line.description += options.description; + } + + if(options.extras !== undefined){ + for (var prop in options.extras) { + line[prop] = options.extras[prop]; + } + } + if (options.is_tip) { + this.is_tipped = true; + this.tip_amount = options.price; + } + + var to_merge_orderline; + for (var i = 0; i < this.orderlines.length; i++) { + if(this.orderlines.at(i).can_be_merged_with(line) && options.merge !== false){ + to_merge_orderline = this.orderlines.at(i); + } + } + if (to_merge_orderline){ + to_merge_orderline.merge(line); + this.select_orderline(to_merge_orderline); + } else { + this.orderlines.add(line); + this.select_orderline(this.get_last_orderline()); + } + + if (options.draftPackLotLines) { + this.selected_orderline.setPackLotLines(options.draftPackLotLines); + } + if (this.pos.config.iface_customer_facing_display) { + this.pos.send_current_order_to_customer_facing_display(); + } + }, + get_selected_orderline: function(){ + return this.selected_orderline; + }, + select_orderline: function(line){ + if(line){ + if(line !== this.selected_orderline){ + // if line (new line to select) is not the same as the old + // selected_orderline, then we set the old line to false, + // and set the new line to true. Also, set the new line as + // the selected_orderline. + if(this.selected_orderline){ + this.selected_orderline.set_selected(false); + } + this.selected_orderline = line; + this.selected_orderline.set_selected(true); + } + }else{ + this.selected_orderline = undefined; + } + }, + deselect_orderline: function(){ + if(this.selected_orderline){ + this.selected_orderline.set_selected(false); + this.selected_orderline = undefined; + } + }, + + /* ---- Payment Lines --- */ + add_paymentline: function(payment_method) { + this.assert_editable(); + var newPaymentline = new exports.Paymentline({},{order: this, payment_method:payment_method, pos: this.pos}); + newPaymentline.set_amount(this.get_due()); + this.paymentlines.add(newPaymentline); + this.select_paymentline(newPaymentline); + if(this.pos.config.cash_rounding){ + this.selected_paymentline.set_amount(0); + this.selected_paymentline.set_amount(this.get_due()); + } + return newPaymentline; + }, + get_paymentlines: function(){ + return this.paymentlines.models; + }, + /** + * Retrieve the paymentline with the specified cid + * + * @param {String} cid + */ + get_paymentline: function (cid) { + var lines = this.get_paymentlines(); + return lines.find(function (line) { + return line.cid === cid; + }); + }, + remove_paymentline: function(line){ + this.assert_editable(); + if(this.selected_paymentline === line){ + this.select_paymentline(undefined); + } + this.paymentlines.remove(line); + }, + clean_empty_paymentlines: function() { + var lines = this.paymentlines.models; + var empty = []; + for ( var i = 0; i < lines.length; i++) { + if (!lines[i].get_amount()) { + empty.push(lines[i]); + } + } + for ( var i = 0; i < empty.length; i++) { + this.remove_paymentline(empty[i]); + } + }, + select_paymentline: function(line){ + if(line !== this.selected_paymentline){ + if(this.selected_paymentline){ + this.selected_paymentline.set_selected(false); + } + this.selected_paymentline = line; + if(this.selected_paymentline){ + this.selected_paymentline.set_selected(true); + } + this.trigger('change:selected_paymentline',this.selected_paymentline); + } + }, + electronic_payment_in_progress: function() { + return this.get_paymentlines() + .some(function(pl) { + if (pl.payment_status) { + return !['done', 'reversed'].includes(pl.payment_status); + } else { + return false; + } + }); + }, + /** + * Stops a payment on the terminal if one is running + */ + stop_electronic_payment: function () { + var lines = this.get_paymentlines(); + var line = lines.find(function (line) { + var status = line.get_payment_status(); + return status && !['done', 'reversed', 'reversing', 'pending', 'retry'].includes(status); + }); + if (line) { + line.set_payment_status('waitingCancel'); + line.payment_method.payment_terminal.send_payment_cancel(this, line.cid).finally(function () { + line.set_payment_status('retry'); + }); + } + }, + /* ---- Payment Status --- */ + get_subtotal: function(){ + return round_pr(this.orderlines.reduce((function(sum, orderLine){ + return sum + orderLine.get_display_price(); + }), 0), this.pos.currency.rounding); + }, + get_total_with_tax: function() { + return this.get_total_without_tax() + this.get_total_tax(); + }, + get_total_without_tax: function() { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + return sum + orderLine.get_price_without_tax(); + }), 0), this.pos.currency.rounding); + }, + get_total_discount: function() { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + sum += (orderLine.get_unit_price() * (orderLine.get_discount()/100) * orderLine.get_quantity()); + if (orderLine.display_discount_policy() === 'without_discount'){ + sum += ((orderLine.get_lst_price() - orderLine.get_unit_price()) * orderLine.get_quantity()); + } + return sum; + }), 0), this.pos.currency.rounding); + }, + get_total_tax: function() { + if (this.pos.company.tax_calculation_rounding_method === "round_globally") { + // As always, we need: + // 1. For each tax, sum their amount across all order lines + // 2. Round that result + // 3. Sum all those rounded amounts + var groupTaxes = {}; + this.orderlines.each(function (line) { + var taxDetails = line.get_tax_details(); + var taxIds = Object.keys(taxDetails); + for (var t = 0; t<taxIds.length; t++) { + var taxId = taxIds[t]; + if (!(taxId in groupTaxes)) { + groupTaxes[taxId] = 0; + } + groupTaxes[taxId] += taxDetails[taxId]; + } + }); + + var sum = 0; + var taxIds = Object.keys(groupTaxes); + for (var j = 0; j<taxIds.length; j++) { + var taxAmount = groupTaxes[taxIds[j]]; + sum += round_pr(taxAmount, this.pos.currency.rounding); + } + return sum; + } else { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + return sum + orderLine.get_tax(); + }), 0), this.pos.currency.rounding); + } + }, + get_total_paid: function() { + return round_pr(this.paymentlines.reduce((function(sum, paymentLine) { + if (paymentLine.is_done()) { + sum += paymentLine.get_amount(); + } + return sum; + }), 0), this.pos.currency.rounding); + }, + get_tax_details: function(){ + var details = {}; + var fulldetails = []; + + this.orderlines.each(function(line){ + var ldetails = line.get_tax_details(); + for(var id in ldetails){ + if(ldetails.hasOwnProperty(id)){ + details[id] = (details[id] || 0) + ldetails[id]; + } + } + }); + + for(var id in details){ + if(details.hasOwnProperty(id)){ + fulldetails.push({amount: details[id], tax: this.pos.taxes_by_id[id], name: this.pos.taxes_by_id[id].name}); + } + } + + return fulldetails; + }, + // Returns a total only for the orderlines with products belonging to the category + get_total_for_category_with_tax: function(categ_id){ + var total = 0; + var self = this; + + if (categ_id instanceof Array) { + for (var i = 0; i < categ_id.length; i++) { + total += this.get_total_for_category_with_tax(categ_id[i]); + } + return total; + } + + this.orderlines.each(function(line){ + if ( self.pos.db.category_contains(categ_id,line.product.id) ) { + total += line.get_price_with_tax(); + } + }); + + return total; + }, + get_total_for_taxes: function(tax_id){ + var total = 0; + + if (!(tax_id instanceof Array)) { + tax_id = [tax_id]; + } + + var tax_set = {}; + + for (var i = 0; i < tax_id.length; i++) { + tax_set[tax_id[i]] = true; + } + + this.orderlines.each(function(line){ + var taxes_ids = line.get_product().taxes_id; + for (var i = 0; i < taxes_ids.length; i++) { + if (tax_set[taxes_ids[i]]) { + total += line.get_price_with_tax(); + return; + } + } + }); + + return total; + }, + get_change: function(paymentline) { + if (!paymentline) { + var change = this.get_total_paid() - this.get_total_with_tax() - this.get_rounding_applied(); + } else { + var change = -this.get_total_with_tax(); + var lines = this.paymentlines.models; + for (var i = 0; i < lines.length; i++) { + change += lines[i].get_amount(); + if (lines[i] === paymentline) { + break; + } + } + } + return round_pr(Math.max(0,change), this.pos.currency.rounding); + }, + get_due: function(paymentline) { + if (!paymentline) { + var due = this.get_total_with_tax() - this.get_total_paid() + this.get_rounding_applied(); + } else { + var due = this.get_total_with_tax(); + var lines = this.paymentlines.models; + for (var i = 0; i < lines.length; i++) { + if (lines[i] === paymentline) { + break; + } else { + due -= lines[i].get_amount(); + } + } + } + return round_pr(due, this.pos.currency.rounding); + }, + get_rounding_applied: function() { + if(this.pos.config.cash_rounding) { + const only_cash = this.pos.config.only_round_cash_method; + const paymentlines = this.get_paymentlines(); + const last_line = paymentlines ? paymentlines[paymentlines.length-1]: false; + const last_line_is_cash = last_line ? last_line.payment_method.is_cash_count == true: false; + if (!only_cash || (only_cash && last_line_is_cash)) { + var remaining = this.get_total_with_tax() - this.get_total_paid(); + var total = round_pr(remaining, this.pos.cash_rounding[0].rounding); + var sign = remaining > 0 ? 1.0 : -1.0; + + var rounding_applied = total - remaining; + rounding_applied *= sign; + // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it. + if (utils.float_is_zero(rounding_applied, this.pos.currency.decimals)){ + // https://xkcd.com/217/ + return 0; + } + else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0 && remaining > 0) { + rounding_applied += this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied > 0 && remaining < 0) { + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0 && remaining > 0){ + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied < 0 && remaining < 0){ + rounding_applied += this.pos.cash_rounding[0].rounding; + } + return sign * rounding_applied; + } + else { + return 0; + } + } + return 0; + }, + has_not_valid_rounding: function() { + if(!this.pos.config.cash_rounding) + return false; + + const only_cash = this.pos.config.only_round_cash_method; + var lines = this.paymentlines.models; + + for(var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (only_cash && !line.payment_method.is_cash_count) + continue; + + if(!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6)) + return line; + } + return false; + }, + is_paid: function(){ + return this.get_due() <= 0 && this.check_paymentlines_rounding(); + }, + is_paid_with_cash: function(){ + return !!this.paymentlines.find( function(pl){ + return pl.payment_method.is_cash_count; + }); + }, + check_paymentlines_rounding: function() { + if(this.pos.config.cash_rounding) { + var cash_rounding = this.pos.cash_rounding[0].rounding; + var default_rounding = this.pos.currency.rounding; + for(var id in this.get_paymentlines()) { + var line = this.get_paymentlines()[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) { + return false; + } else if(!this.pos.config.only_round_cash_method && diff) { + return false; + } + } + return true; + } + return true; + }, + finalize: function(){ + this.destroy(); + }, + destroy: function(){ + Backbone.Model.prototype.destroy.apply(this,arguments); + this.pos.db.remove_unpaid_order(this); + }, + /* ---- Invoice --- */ + set_to_invoice: function(to_invoice) { + this.assert_editable(); + this.to_invoice = to_invoice; + }, + is_to_invoice: function(){ + return this.to_invoice; + }, + /* ---- Client / Customer --- */ + // the client related to the current order. + set_client: function(client){ + this.assert_editable(); + this.set('client',client); + }, + get_client: function(){ + return this.get('client'); + }, + get_client_name: function(){ + var client = this.get('client'); + return client ? client.name : ""; + }, + get_cardholder_name: function(){ + var card_payment_line = this.paymentlines.find(pl => pl.cardholder_name); + return card_payment_line ? card_payment_line.cardholder_name : ""; + }, + /* ---- Screen Status --- */ + // the order also stores the screen status, as the PoS supports + // different active screens per order. This method is used to + // store the screen status. + set_screen_data: function(value){ + this.screen_data['value'] = value; + }, + //see set_screen_data + get_screen_data: function(){ + const screen = this.screen_data['value']; + // If no screen data is saved + // no payment line -> product screen + // with payment line -> payment screen + if (!screen) { + if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' }; + return { name: 'ProductScreen' }; + } + if (!this.finalized && this.get_paymentlines().length > 0) { + return { name: 'PaymentScreen' }; + } + return screen; + }, + wait_for_push_order: function () { + return false; + }, + /** + * @returns {Object} object to use as props for instantiating OrderReceipt. + */ + getOrderReceiptEnv: function() { + // Formerly get_receipt_render_env defined in ScreenWidget. + return { + order: this, + receipt: this.export_for_printing(), + orderlines: this.get_orderlines(), + paymentlines: this.get_paymentlines(), + }; + }, + updatePricelist: function(newClient) { + let newClientPricelist, newClientFiscalPosition; + const defaultFiscalPosition = this.pos.fiscal_positions.find( + (position) => position.id === this.pos.config.default_fiscal_position_id[0] + ); + if (newClient) { + newClientFiscalPosition = newClient.property_account_position_id + ? this.pos.fiscal_positions.find( + (position) => position.id === newClient.property_account_position_id[0] + ) + : defaultFiscalPosition; + newClientPricelist = + this.pos.pricelists.find( + (pricelist) => pricelist.id === newClient.property_product_pricelist[0] + ) || this.pos.default_pricelist; + } else { + newClientFiscalPosition = defaultFiscalPosition; + newClientPricelist = this.pos.default_pricelist; + } + this.fiscal_position = newClientFiscalPosition; + this.set_pricelist(newClientPricelist); + } +}); + +var OrderCollection = Backbone.Collection.extend({ + model: exports.Order, +}); + +// exports = { +// PosModel: PosModel, +// load_fields: load_fields, +// load_models: load_models, +// Orderline: Orderline, +// Order: Order, +// }; +return exports; + +}); diff --git a/addons/point_of_sale/static/src/js/payment.js b/addons/point_of_sale/static/src/js/payment.js new file mode 100644 index 00000000..ae73f552 --- /dev/null +++ b/addons/point_of_sale/static/src/js/payment.js @@ -0,0 +1,95 @@ +odoo.define('point_of_sale.PaymentInterface', function (require) { +"use strict"; + +var core = require('web.core'); + +/** + * Implement this interface to support a new payment method in the POS: + * + * var PaymentInterface = require('point_of_sale.PaymentInterface'); + * var MyPayment = PaymentInterface.extend({ + * ... + * }) + * + * To connect the interface to the right payment methods register it: + * + * var models = require('point_of_sale.models'); + * models.register_payment_method('my_payment', MyPayment); + * + * my_payment is the technical name of the added selection in + * use_payment_terminal. + * + * If necessary new fields can be loaded on any model: + * + * models.load_fields('pos.payment.method', ['new_field1', 'new_field2']); + */ +var PaymentInterface = core.Class.extend({ + init: function (pos, payment_method) { + this.pos = pos; + this.payment_method = payment_method; + this.supports_reversals = false; + }, + + /** + * Call this function to enable UI elements that allow a user to + * reverse a payment. This requires that you implement + * send_payment_reversal. + */ + enable_reversals: function () { + this.supports_reversals = true; + }, + + /** + * Called when a user clicks the "Send" button in the + * interface. This should initiate a payment request and return a + * Promise that resolves when the final status of the payment line + * is set with set_payment_status. + * + * For successful transactions set_receipt_info() should be used + * to set info that should to be printed on the receipt. You + * should also set card_type and transaction_id on the line for + * successful transactions. + * + * @param {string} cid - The id of the paymentline + * @returns {Promise} resolved with a boolean that is false when + * the payment should be retried. Rejected when the status of the + * paymentline will be manually updated. + */ + send_payment_request: function (cid) {}, + + /** + * Called when a user removes a payment line that's still waiting + * on send_payment_request to complete. Should execute some + * request to ensure the current payment request is + * cancelled. This is not to refund payments, only to cancel + * them. The payment line being cancelled will be deleted + * automatically after the returned promise resolves. + * + * @param {} order - The order of the paymentline + * @param {string} cid - The id of the paymentline + * @returns {Promise} + */ + send_payment_cancel: function (order, cid) {}, + + /** + * This is an optional method. When implementing this make sure to + * call enable_reversals() in the constructor of your + * interface. This should reverse a previous payment with status + * 'done'. The paymentline will be removed based on returned + * Promise. + * + * @param {string} cid - The id of the paymentline + * @returns {Promise} returns true if the reversal was successful. + */ + send_payment_reversal: function (cid) {}, + + /** + * Called when the payment screen in the POS is closed (by + * e.g. clicking the "Back" button). Could be used to cancel in + * progress payments. + */ + close: function () {}, +}); + +return PaymentInterface; +}); diff --git a/addons/point_of_sale/static/src/js/printers.js b/addons/point_of_sale/static/src/js/printers.js new file mode 100644 index 00000000..20ea4454 --- /dev/null +++ b/addons/point_of_sale/static/src/js/printers.js @@ -0,0 +1,172 @@ +odoo.define('point_of_sale.Printer', function (require) { +"use strict"; + +var Session = require('web.Session'); +var core = require('web.core'); +const { Gui } = require('point_of_sale.Gui'); +var _t = core._t; + +// IMPROVEMENT: This is too much. We can get away from this class. +class PrintResult { + constructor({ successful, message }) { + this.successful = successful; + this.message = message; + } +} + +class PrintResultGenerator { + IoTActionError() { + return new PrintResult({ + successful: false, + message: { + title: _t('Connection to IoT Box failed'), + body: _t('Please check if the IoT Box is still connected.'), + }, + }); + } + IoTResultError() { + return new PrintResult({ + successful: false, + message: { + title: _t('Connection to the printer failed'), + body: _t('Please check if the printer is still connected.'), + }, + }); + } + Successful() { + return new PrintResult({ + successful: true, + }); + } +} + +var PrinterMixin = { + init: function() { + this.receipt_queue = []; + this.printResultGenerator = new PrintResultGenerator(); + }, + + /** + * Add the receipt to the queue of receipts to be printed and process it. + * We clear the print queue if printing is not successful. + * @param {String} receipt: The receipt to be printed, in HTML + * @returns {PrintResult} + */ + print_receipt: async function(receipt) { + if (receipt) { + this.receipt_queue.push(receipt); + } + let image, sendPrintResult; + while (this.receipt_queue.length > 0) { + receipt = this.receipt_queue.shift(); + image = await this.htmlToImg(receipt); + try { + sendPrintResult = await this.send_printing_job(image); + } catch (error) { + // Error in communicating to the IoT box. + this.receipt_queue.length = 0; + return this.printResultGenerator.IoTActionError(); + } + // rpc call is okay but printing failed because + // IoT box can't find a printer. + if (!sendPrintResult || sendPrintResult.result === false) { + this.receipt_queue.length = 0; + return this.printResultGenerator.IoTResultError(); + } + } + return this.printResultGenerator.Successful(); + }, + + /** + * Generate a jpeg image from a canvas + * @param {DOMElement} canvas + */ + process_canvas: function (canvas) { + return canvas.toDataURL('image/jpeg').replace('data:image/jpeg;base64,',''); + }, + + /** + * Renders the html as an image to print it + * @param {String} receipt: The receipt to be printed, in HTML + */ + htmlToImg: function (receipt) { + var self = this; + $('.pos-receipt-print').html(receipt); + var promise = new Promise(function (resolve, reject) { + self.receipt = $('.pos-receipt-print>.pos-receipt'); + html2canvas(self.receipt[0], { + onparsed: function(queue) { + queue.stack.ctx.height = Math.ceil(self.receipt.outerHeight() + self.receipt.offset().top); + }, + onrendered: function (canvas) { + $('.pos-receipt-print').empty(); + resolve(self.process_canvas(canvas)); + }, + letterRendering: true, + }) + }); + return promise; + }, + + _onIoTActionResult: function (data){ + if (this.pos && (data === false || data.result === false)) { + Gui.showPopup('ErrorPopup',{ + 'title': _t('Connection to the printer failed'), + 'body': _t('Please check if the printer is still connected.'), + }); + } + }, + + _onIoTActionFail: function () { + if (this.pos) { + Gui.showPopup('ErrorPopup',{ + 'title': _t('Connection to IoT Box failed'), + 'body': _t('Please check if the IoT Box is still connected.'), + }); + } + }, +} + +var Printer = core.Class.extend(PrinterMixin, { + init: function (url, pos) { + PrinterMixin.init.call(this, arguments); + this.pos = pos; + this.connection = new Session(undefined, url || 'http://localhost:8069', { use_cors: true}); + }, + + /** + * Sends a command to the connected proxy to open the cashbox + * (the physical box where you store the cash). Updates the status of + * the printer with the answer from the proxy. + */ + open_cashbox: function () { + var self = this; + return this.connection.rpc('/hw_proxy/default_printer_action', { + data: { + action: 'cashbox' + } + }).then(self._onIoTActionResult.bind(self)) + .guardedCatch(self._onIoTActionFail.bind(self)); + }, + + /** + * Sends the printing command the connected proxy + * @param {String} img : The receipt to be printed, as an image + */ + send_printing_job: function (img) { + return this.connection.rpc('/hw_proxy/default_printer_action', { + data: { + action: 'print_receipt', + receipt: img, + } + }); + }, +}); + +return { + PrinterMixin: PrinterMixin, + Printer: Printer, + PrintResult, + PrintResultGenerator, +} +}); diff --git a/addons/point_of_sale/static/src/js/tours/point_of_sale.js b/addons/point_of_sale/static/src/js/tours/point_of_sale.js new file mode 100644 index 00000000..49a6fc0a --- /dev/null +++ b/addons/point_of_sale/static/src/js/tours/point_of_sale.js @@ -0,0 +1,31 @@ +odoo.define('point_of_sale.tour', function(require) { +"use strict"; + +var core = require('web.core'); +var tour = require('web_tour.tour'); + +var _t = core._t; + +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: _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: _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: _t("<p>Ready to have a look at the <b>POS Interface</b>? Let's start our first session.</p>"), + position: "bottom" +}]); + +}); diff --git a/addons/point_of_sale/static/src/js/utils.js b/addons/point_of_sale/static/src/js/utils.js new file mode 100644 index 00000000..7aa7b35e --- /dev/null +++ b/addons/point_of_sale/static/src/js/utils.js @@ -0,0 +1,49 @@ +odoo.define('point_of_sale.utils', function (require) { + 'use strict'; + + const { EventBus } = owl.core; + + function getFileAsText(file) { + return new Promise((resolve, reject) => { + if (!file) { + reject(); + } else { + const reader = new FileReader(); + reader.addEventListener('load', function () { + resolve(reader.result); + }); + reader.addEventListener('abort', reject); + reader.addEventListener('error', reject); + reader.readAsText(file); + } + }); + } + + /** + * This global variable is used by nextFrame to store the timer and + * be able to cancel it before another request for animation frame. + */ + let timer = null; + + /** + * Wait for the next animation frame to finish. + */ + const nextFrame = () => { + return new Promise((resolve) => { + cancelAnimationFrame(timer); + timer = requestAnimationFrame(() => { + resolve(); + }); + }); + }; + + function isRpcError(error) { + return ( + !(error instanceof Error) && + error.message && + [100, 200, 404, -32098].includes(error.message.code) + ); + } + + return { getFileAsText, nextFrame, isRpcError, posbus: new EventBus() }; +}); diff --git a/addons/point_of_sale/static/src/scss/customer_facing_display.scss b/addons/point_of_sale/static/src/scss/customer_facing_display.scss new file mode 100644 index 00000000..b1c85ac6 --- /dev/null +++ b/addons/point_of_sale/static/src/scss/customer_facing_display.scss @@ -0,0 +1,475 @@ +// out: ../css/customer_facing_display.css, sourcemap: false, compress: false + +// =========== Variables =========== +$color-gray-lighter: #f6f6f6; +$color-gray-dark: #3E3E3E; + +// =========== Animations =========== +@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; } +} + +// =========== MIXINS =========== +@mixin pos-bg { + background-position: center top; + background-size: contain; + background-repeat: no-repeat; + + &[style*="url(http://placehold.it"] { + // Add a bg-color in case we are using a pleceholder. + // This will help the user to identify the right image dimension + // before apply customizations. + background-color: #ccc; + } +} + +// =========== VENDOR PREFIX =========== +@mixin flex-display { + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; +} +@mixin flex-direction($direction) { + -webkit-flex-direction: $direction; + -moz-flex-direction: $direction; + -ms-flex-direction: $direction; + -o-flex-direction: $direction; + flex-direction: $direction; +}; +@mixin flex-grow($grow) { + -webkit-box-flex: $grow; + -webkit-flex-grow: $grow; + -moz-box-flex: $grow; + -ms-flex-positive: $grow; + flex-grow: $grow; +} +@mixin flex($flex) { + -webkit-box-flex: $flex; + -webkit-flex: $flex; + -moz-box-flex: $flex; + -ms-flex: $flex; + flex: $flex; +} +@mixin align-items($align) { + -webkit-box-align: $align; + -webkit-align-items: $align; + -moz-box-align: $align; + -ms-flex-align: $align; + -ms-grid-row-align: $align; + align-items: $align; +}; +@mixin justify-content($justify) { + -webkit-box-pack: $justify; + -webkit-justify-content: $justify; + -moz-box-pack: $justify; + -ms-flex-pack: $justify; + justify-content: $justify; +} +@mixin flex-wrap($wrap) { + -webkit-flex-wrap: $wrap; + -ms-flex-wrap: $wrap; + flex-wrap: $wrap; +} + + +// =========== MAIN LAYOUT =========== +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + font-smooth: always; + + .pos-customer_facing_display { + background-color: $color-gray-lighter; + font-size: 2vw; + font-family: Futura, HelveticaNeue, Helvetica, Arial, "Lucida Grande", sans-serif; + font-weight: 300; + width: 100%; + height: 100%; + padding: 0; + @include flex-display; + @include flex-direction(row); + + .pos-customer_products, + .pos-payment_info { + height: 100%; + padding: 2%; + @include flex-display; + @include flex-direction(column); + @include flex-grow(1); + } + + .pos_orderlines { + width: 100%; + height: 100%; + @include flex-display; + @include flex-direction(column); + + .pos_orderlines_list { + overflow-y: scroll; + padding-right: 1.5vw; + position: relative; + height: 100%; + } + + .pos_orderlines_item { + margin-bottom: 1vw; + padding: 1%; + border-radius: 0.3vw; + height: auto; + @include flex(0 1 auto); + @include flex-display; + @include flex-direction(row); + @include align-items(center); + + &:last-of-type { + animation: item_in 1s ease; + } + + &.pos_orderlines_header { + background-color: transparent; + box-shadow: none; + animation: none; + + > div { + &, + &:last-child { + border-left-width: 0; + text-align: center; + font-size: 70%; + font-weight: normal; + } + } + + > div:last-child { + text-align: left; + } + } + + > div { + width: 5%; + text-align: left; + margin-right: 4%; + font-size: 80%; + @include flex-grow(1); + + &:first-child { + margin-right: 2%; + @include flex(1 1 1%); + } + + &:nth-child(2) { + width: 40%; + border-left: 1px solid; + padding-left: 2%; + } + + &:nth-child(3) { + text-align: center; + } + + &:last-child { + margin-right: 0; + font-weight: bold; + } + + div { + background-position: center; + background-size: cover; + padding-top: 75%; + display: block; + } + } + } + } + + .pos-payment_info { + max-width: 30%; + padding: 2% 2% 1% 2%; + @include flex-direction(column); + @include justify-content(space-between); + + .pos-adv, + .pos-company_logo { + @include pos-bg; + } + + .pos-company_logo { + background-image: url(/logo); + margin-bottom: 10%; + @include flex(0 0 20%); + } + + .pos-adv { + margin-bottom: 5%; + border-bottom: 10px solid transparent; + box-shadow: 0 1px rgba($color-gray-lighter, 0.2); + @include flex(1 1 60%); + } + + .pos-payment_info_details{ + .pos-total, + .pos-paymentlines { + @include flex-direction(row); + @include flex-display; + @include flex-wrap(wrap); + @include justify-content(space-between); + @include align-items(baseline); + + > div { + @include flex(1 0 48%); + + &:nth-child(even) { + font-weight: bold; + font-size: 120%; + margin-right: 0 + } + } + } + + .pos-total { + font-size: 2vw; + } + + .pos-paymentlines { + margin-top: 2%; + font-size: 1.5vw; + line-height: 1.3; + } + + .pos-odoo_logo_container { + text-align: right; + margin-top: 10%; + @include flex(0 1 auto); + + img { + max-width: 40px; + } + } + } + } + } + + // =========== PORTRAIT LAYOUT =========== + @media all and (orientation: portrait) { + .pos-customer_facing_display { + font-size: 2vh; + height: 100%; + @include flex-direction(column); + + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 17vh; + } + + .pos-payment_info .pos-adv { + // Move ADV on top. + position: fixed; + top: 0; + left: 0; + height: 15vh; + width: 99vw; + margin:0.5vh; + border-width: 0; + @include flex-display; + } + + &.pos-js_no_ADV { + &:before { + display: none; + } + .pos-customer_products { + padding-top: 0; + } + } + + .pos-customer_products { + padding-top: 17vh; + height: 72vw; + overflow: hidden; + + .pos_orderlines { + @include flex(1 0 auto); + + .pos_orderlines_item { + > div:nth-child(2) { + width: 30%; + } + &.pos_orderlines_header div{ + font-size: 90%; + } + } + + .pos_orderlines_list { + padding-right: 1.5vh; + height: auto; + .pos_orderlines_item { + box-shadow: 0 .1vh .1vh darken($color-gray-lighter, 10%); + margin-bottom: 1vh; + > div { + font-size: 100%; + } + } + } + } + } + + .pos-payment_info { + max-width: 100%; + overflow: hidden; + padding-top: 0; + min-height: 120px; + @include flex(0 1 23vw); + @include flex-direction(row); + @include align-items(center); + @include justify-content(space-between); + + .pos-company_logo { + margin: 0; + background-position: left center; + margin-right: 5%; + height: 100%; + padding: 0; + @include flex(1 1 20%); + } + + .pos-payment_info_details { + @include flex(0 1 50%); + @include flex-direction(column); + min-width: 170px; + + .pos-total { + font-size: 3vw; + .pos_total-amount { + font-size: 3.5vw; + } + } + + .pos-paymentlines { + margin-top: 2%; + font-size: 80%; + line-height: 1.2; + } + .pos-odoo_logo_container { + position: absolute; + right: 3%; + bottom: 1%; + } + } + } + } + } + + @media all and (orientation: portrait) and (max-width: 340px ) { + .pos-customer_facing_display { + .pos-customer_products .pos_orderlines { + .pos_orderlines_list{ + padding-right: 0; + .pos_orderlines_item > div{ + font-size: 70%; + } + } + .pos_orderlines_header > div { + font-size: 60%; + &:last-child { + text-align: center; + } + } + } + .pos-payment_info { + + .pos-company_logo { + display: none!important; + } + .pos-payment_info_details { + @include flex(1 0 100%); + + .pos-total { + font-size: 6vw; + .pos_total-amount { + font-size: 6.5vw; + } + } + } + } + } + } +} + + +// @media all and (max-width: 340px ) { +// } + +body { + // =========== UTILITY CLASSES =========== + .pos-hidden { + opacity: 0; + } +} + + +// =========== PALETTE GENERATOR =========== +@mixin palette-variant ( + $bg-info: $color-gray-dark, + $text-info: $color-gray-lighter, + $bg-products: $color-gray-lighter, + $text-products: lighten($color-gray-dark, 10%), + $card-bg: white, + $card-text: $color-gray-dark, + $card-shadow: darken($bg-products, 30%)) { + + .pos-payment_info { + background: $bg-info; + color: $text-info; + } + + .pos-customer_products { + background: $bg-products; + color: $text-products; + + .pos_orderlines_list .pos_orderlines_item { + background-color: $card-bg; + color: $card-text; + box-shadow: 0 .1vh .1vh $card-shadow; + div:nth-child(2) { + border-color: rgba($card-text, 0.3); + } + } + } + + @media all and (orientation: portrait) { + &:before { + background: $bg-info; + } + } +} + +// =========== PALETTES =========== +// Those are kept for compatibility for now (previously there was a feature +// which allowed to change the default pos-palette_01 class. +.pos-palette_01 { @include palette-variant; } +.pos-palette_02 { @include palette-variant(#364152, #e6e7e8, #ecf2f6, #364152, white, $color-gray-dark, #364152 ); } +.pos-palette_03 { @include palette-variant(#1BA39C, $color-gray-lighter, #ececec ); } +.pos-palette_04 { @include palette-variant(#0b7b6c, $color-gray-lighter, #efeeec); } +.pos-palette_05 { @include palette-variant(#E26868, $color-gray-lighter, #ececec ); } +.pos-palette_06 { @include palette-variant(#9E373B, $color-gray-lighter); } +.pos-palette_07 { @include palette-variant(#ce9934, white, #ececec ); } +.pos-palette_08 { @include palette-variant(#a48c77, $color-gray-lighter, #ececec ); } +.pos-palette_09 { @include palette-variant(linear-gradient(30deg, #014d43, #127e71), $color-gray-lighter, #ececec ); } +.pos-palette_10 { @include palette-variant(linear-gradient(30deg, #e2316c, #ea4c89), white, #ececec ); } +.pos-palette_11 { @include palette-variant(linear-gradient(30deg, #362b3d, #5b4a63), white, #ececec ); } +.pos-palette_12 { @include palette-variant(#434343, #e6e6e6, #5b5b5b, #bdb9b9, #f5f5f5); } +.pos-palette_13 { @include palette-variant(linear-gradient(30deg, #1a1b1f, #3d3f45), white, #a2a2ab, $color-gray-lighter, $color-gray-lighter); } diff --git a/addons/point_of_sale/static/src/scss/pos_dashboard.scss b/addons/point_of_sale/static/src/scss/pos_dashboard.scss new file mode 100644 index 00000000..469fd998 --- /dev/null +++ b/addons/point_of_sale/static/src/scss/pos_dashboard.scss @@ -0,0 +1,5 @@ +.o_kanban_view.o_kanban_dashboard.o_pos_kanban.o_kanban_ungrouped { + .o_kanban_record { + width: 500px; + } +} diff --git a/addons/point_of_sale/static/src/sounds/bell.wav b/addons/point_of_sale/static/src/sounds/bell.wav Binary files differnew file mode 100644 index 00000000..660779c5 --- /dev/null +++ b/addons/point_of_sale/static/src/sounds/bell.wav diff --git a/addons/point_of_sale/static/src/sounds/error.wav b/addons/point_of_sale/static/src/sounds/error.wav Binary files differnew file mode 100644 index 00000000..472f3910 --- /dev/null +++ b/addons/point_of_sale/static/src/sounds/error.wav diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml new file mode 100644 index 00000000..17593b2f --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Chrome.xml @@ -0,0 +1,136 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="Chrome" owl="1"> + <div class="pos" t-att-class="{ 'big-scrollbars': state.hasBigScrollBars }"> + <div class="pos-receipt-print"></div> + <div class="pos-topheader" t-att-class="{ oe_hidden: state.uiState !== 'READY' }"> + <div t-if="tempScreen.isShown" class="block-top-header" /> + <div class="pos-branding" t-if= "!env.isMobile"> + <img class="pos-logo" t-on-click="trigger('toggle-debug-widget')" + src="/point_of_sale/static/src/img/logo.png" alt="Logo" /> + <TicketButton isTicketScreenShown="isTicketScreenShown" /> + </div> + <div class="pos-rightheader"> + <TicketButton isTicketScreenShown="isTicketScreenShown" t-if="env.isMobile" /> + <div class="search-bar-portal" /> + <div class="status-buttons-portal" /> + </div> + </div> + <t t-if="state.uiState === 'READY'"> + <Portal target="'.pos .status-buttons-portal'"> + <div class="status-buttons"> + <t t-if="!env.isMobile"> + <CashierName /> + </t> + <OrderManagementButton t-if="env.pos.config.manage_orders" /> + <SaleDetailsButton t-if="env.pos.proxy.printer" /> + <ProxyStatus t-if="env.pos.config.use_proxy" /> + <ClientScreenButton t-if="clientScreenButtonIsShown" /> + <SyncNotification /> + <HeaderButton /> + </div> + </Portal> + <div class="pos-content"> + <div class="window"> + <div class="subwindow"> + <div class="subwindow-container"> + <div class="subwindow-container-fix screens"> + <t isShown="!tempScreen.isShown" t-component="mainScreen.component" + t-props="mainScreenProps" t-key="mainScreen.name" /> + <t t-if="tempScreen.isShown" t-component="tempScreen.component" + t-props="tempScreenProps" t-key="tempScreen.name" /> + </div> + </div> + </div> + </div> + <DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown" + t-transition="fade" /> + </div> + </t> + + <div t-if="['LOADING', 'CLOSING'].includes(state.uiState)" class="loader" t-transition="swing"> + <div class="loader-feedback"> + <h1 class="message"> + <t t-esc="loading.message" /> + </h1> + <div class="progressbar"> + <div class="progress" t-ref="progressbar"></div> + </div> + <div t-if="loading.skipButtonIsShown" class="button skip" t-on-click="trigger('loading-skip-callback')"> + Skip + </div> + </div> + </div> + + <!-- Allow popups to be visible at any state of the ui. --> + <div t-if="popup.isShown" class="popups"> + <t t-component="popup.component" t-props="popupProps" + t-key="popup.name" /> + </div> + + <NotificationSound t-if="state.sound.src" sound="state.sound" /> + </div> + </t> + + <t t-name="CustomerFacingDisplayHead"> + <div class="resources"> + <base t-att-href="origin" /> + <link href="/point_of_sale/static/src/css/customer_facing_display.css" + rel="stylesheet" /> + <script type="text/javascript"> + // This function needs to be named that way, call it the foreign JS API + // The iotbox will execute it, with the behavior intended + function foreign_js() { + if ($('.pos-adv').hasClass('pos-hidden')) { + $('.pos-customer_facing_display').addClass('pos-js_no_ADV'); + } + $(window).on('resize', function () { + $('.pos-customer_facing_display').toggleClass('pos-js_no_ADV', $('.pos-adv').hasClass('pos-hidden')); + }).trigger('resize'); + }; + </script> + </div> + </t> + + <t t-name="CustomerFacingDisplayOrderLines"> + <t t-foreach="orderlines" t-as="orderline"> + <div class="pos_orderlines_item"> + <div> + <div t-attf-style="background-image:url(#{orderline.product.image_base64})" /> + </div> + <div> + <t t-esc="orderline.get_full_product_name()" /> + </div> + <div> + <t t-esc="orderline.get_quantity_str()" /> + </div> + <div> + <t t-esc="pos.format_currency(orderline.get_display_price())" /> + </div> + </div> + </t> + </t> + + <t t-name="CustomerFacingDisplayPaymentLines"> + <t t-foreach="order.get_paymentlines()" t-as="paymentline"> + <div> + <span> + <t t-esc="paymentline.name" /></span> + </div> + <div> + <span> + <t t-esc="pos.format_currency(paymentline.get_amount())" /></span> + </div> + </t> + <t t-if="order.get_paymentlines().length > 0"> + <div> + <span class="pos-change_title">Change:</span> + </div> + <div> + <span class="pos-change_amount"> + <t t-esc="pos.format_currency(order.get_change())" /></span> + </div> + </t> + </t> +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml new file mode 100644 index 00000000..41b7ee69 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashierName.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="CashierName" owl="1"> + <div class="oe_status"> + <span class="username"> + <t t-esc="username" /> + </span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml new file mode 100644 index 00000000..bbcb1167 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ClientScreenButton.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientScreenButton" owl="1"> + <div class="oe_status" t-on-click="onClick"> + <span class="message"><t t-esc="message" /></span> + <div t-if="state.status === 'warning'" class="js_warning oe_icon oe_orange"> + <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Warning" title="Client Screen Warning"></i> + </div> + <div t-if="state.status === 'failure'" class="js_disconnected oe_icon oe_red"> + <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Disconnected" title="Client Screen Disconnected"></i> + </div> + <div t-if="state.status === 'success'" class="js_connected oe_icon oe_green"> + <i class="fa fa-fw fa-desktop" role="img" aria-label="Client Screen Connected" title="Client Screen Connected"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml new file mode 100644 index 00000000..6e67512e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/DebugWidget.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="DebugWidget" owl="1"> + <Draggable limitArea="'.pos'"> + <div class="debug-widget"> + <header class="drag-handle"> + <h1>Debug Window</h1> + </header> + <div class="toggle" t-on-click="trigger('toggle-debug-widget')" title="Dismiss" + role="img" aria-label="Dismiss"><i class="fa fa-times" /></div> + <div class="content"> + <p class="category">Electronic Scale</p> + <ul> + <li> + <input t-model="state.weightInput" type="text" class="weight"></input> + </li> + <li class="button set_weight" t-on-click="setWeight">Set Weight</li> + <li class="button reset_weight" t-on-click="resetWeight">Reset</li> + </ul> + + <p class="category">Barcode Scanner</p> + <ul> + <li> + <input t-model="state.barcodeInput" type="text" class="ean"></input> + </li> + <li class="button barcode" t-on-click="barcodeScan">Scan</li> + <li class="button custom_ean" t-on-click="barcodeScanEAN">Scan EAN-13</li> + </ul> + + <p class="category">Orders</p> + + <ul> + <li class="button" t-on-click="deleteOrders"> + Delete Paid Orders + </li> + <li class="button" t-on-click="deleteUnpaidOrders"> + Delete Unpaid Orders + </li> + <li t-if="!state.isPaidOrdersReady" class="button" + t-on-click="preparePaidOrders"> + Export Paid Orders + </li> + <a t-else="" t-att-download="paidOrdersFilename" t-att-href="paidOrdersURL" + t-on-click="state.isPaidOrdersReady = !state.isPaidOrdersReady"> + <li class="button"> + Download Paid Orders + </li> + </a> + <li t-if="!state.isUnpaidOrdersReady" class="button" + t-on-click="prepareUnpaidOrders"> + Export Unpaid Orders + </li> + <a t-else="" t-att-download="unpaidOrdersFilename" + t-att-href="unpaidOrdersURL" + t-on-click="state.isUnpaidOrdersReady = !state.isUnpaidOrdersReady"> + <li class="button"> + Download Unpaid Orders + </li> + </a> + <li class="button import_orders" style="position:relative"> + Import Orders + <input t-on-change="importOrders" type="file" + style="opacity:0;position:absolute;top:0;left:0;right:0;bottom:0;margin:0;cursor:pointer" /> + </li> + </ul> + + <p class="category">Hardware Status</p> + <ul> + <li class="status weighing">Weighing</li> + <li class="button display_refresh" t-on-click="refreshDisplay"> + Refresh Display + </li> + </ul> + <p class="category">Hardware Events</p> + <ul> + <li class="event" t-ref="open_cashbox">Open Cashbox</li> + <li class="event" t-ref="print_receipt">Print Receipt</li> + <li class="event" t-ref="scale_read">Read Weighing Scale</li> + </ul> + <p class="category">Others</p> + <ul> + <li class="event"> + <span>Buffer: </span> + <t t-esc="bufferRepr" /> + </li> + </ul> + </div> + </div> + </Draggable> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml new file mode 100644 index 00000000..19d9c7c8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="HeaderButton" owl="1"> + <div class="header-button close_button" t-att-class="{ confirm: state.label === 'Confirm' }" + t-on-click="onClick"> + <t t-esc="translatedLabel" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml new file mode 100644 index 00000000..062e11c3 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/OrderManagementButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderManagementButton" owl="1"> + <div class="oe_status order-management" t-on-click="onClick"> + <div class="oe_icon oe_green"> + <i class="fa fa-fw fa-search" role="img" aria-label="Order Management Button" title="Order Management Button"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml new file mode 100644 index 00000000..3bcbef6d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/ProxyStatus.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProxyStatus" owl="1"> + <div class="oe_status js_proxy" t-on-click="onClick"> + <span t-if="state.msg and !env.isMobile" class="js_msg"> + <t t-esc="state.msg" /> + </span> + <span t-if="state.status === 'connected'" class="js_connected oe_green"> + <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Connected" + title="Proxy Connected"></i> + </span> + <span t-if="state.status === 'connecting'" class="js_connecting"> + <i class="fa fa-fw fa-spin fa-spinner" role="img" aria-label="Connecting to Proxy" + title="Connecting to Proxy"></i> + </span> + <span t-if="state.status === 'warning'" class="js_warning oe_orange"> + <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Warning" + title="Proxy Warning"></i> + </span> + <span t-if="state.status === 'disconnected'" class="js_disconnected oe_red"> + <i class="fa fa-fw fa-sitemap" role="img" aria-label="Proxy Disconnected" + title="Proxy Disconnected"></i> + </span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml new file mode 100644 index 00000000..dc5ecc04 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SaleDetailsButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SaleDetailsButton" owl="1"> + <div class="oe_status"> + <div class="js_connected oe_icon"> + <i class="fa fa-fw fa-print" role="img" aria-label="Print" t-on-click="onClick" + title="Print a report with all the sales of the current PoS Session"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml new file mode 100644 index 00000000..4a08c7ac --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/SyncNotification.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SyncNotification" owl="1"> + <div class="oe_status" t-on-click="onClick"> + <span t-if="state.msg" class="js_msg"> + <t t-esc="state.msg" /> + <span> </span> + </span> + <div t-if="state.status === 'connected'" class="js_connected oe_icon oe_green"> + <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Connected" + title="Synchronisation Connected"></i> + </div> + <div t-if="state.status === 'connecting'" class="js_connecting oe_icon"> + <i class="fa fa-fw fa-spin fa-spinner" role="img" + aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i> + </div> + <div t-if="state.status === 'disconnected'" class="js_disconnected oe_icon oe_red"> + <i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Disconnected" + title="Synchronisation Disconnected"></i> + </div> + <div t-if="state.status === 'error'" class="js_error oe_icon oe_red"> + <i class="fa fa-fw fa-warning" role="img" aria-label="Synchronisation Error" + title="Synchronisation Error"></i> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml new file mode 100644 index 00000000..8a1a3a32 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/TicketButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TicketButton" owl="1"> + <div class="ticket-button" t-att-class="{ highlight: props.isTicketScreenShown }" t-on-click="onClick"> + <div class="with-badge" t-att-badge="count"> + <i class="fa fa-ticket" aria-hidden="true"></i> + </div> + <div>Orders</div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/Draggable.xml b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml new file mode 100644 index 00000000..c0449381 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/Draggable.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="Draggable" owl="1"> + <t t-slot="default"></t> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml b/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml new file mode 100644 index 00000000..883631a2 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/MobileOrderWidget.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="MobileOrderWidget" owl="1"> + <div class="switchpane"> + <t t-if="pane === 'right'"> + <button class="btn-switchpane" t-on-click="trigger('click-pay')"> + <h1>Pay</h1> + <span><t t-esc="total" /></span> + </button> + <button class="btn-switchpane secondary" t-on-click="trigger('switchpane')"> + <h1>Review</h1> + <span><t t-esc="items_number"/> items</span> + </button> + </t> + <t t-if="pane === 'left'"> + <button class="btn-switchpane" t-on-click="trigger('switchpane')"><h1>Back</h1></button> + </t> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml new file mode 100644 index 00000000..6467e807 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/NotificationSound.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="NotificationSound" owl="1"> + <audio t-att-src="props.sound.src" autoplay="true"></audio> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml new file mode 100644 index 00000000..a480f169 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/SearchBar.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="point_of_sale.SearchBar" owl="1"> + <div class="pos-search-bar"> + <div class="search"> + <span class="search-icon"><i class="fa fa-search"></i></span> + <input class="radius-left" t-att-class="{ 'radius-right': !props.config.filter.show }" + t-model="state.searchInput" t-on-keydown="onKeydown" type="text" t-att-placeholder="placeholder" /> + <ul t-if="state.showSearchFields and state.searchInput" class="fields"> + <t t-foreach="config.searchFields" t-as="value" t-key="value_index"> + <li t-att-class="{ highlight: value_index == state.selectedFieldId }" + t-on-click="onClickSearchField(value_index)"> + <span class="field"> + <t t-esc="value"></t> + </span> + <span>: </span> + <span class="term"> + <t t-esc="state.searchInput"></t> + </span> + </li> + </t> + </ul> + </div> + <div t-if="props.config.filter.show" class="filter radius-right" + t-on-click.stop="state.showFilterOptions = !state.showFilterOptions"> + <span class="down-icon"> + <i class="fa fa-chevron-down" aria-hidden="true"></i> + </span> + <span> + <t t-esc="state.selectedFilter" /> + </span> + <ul t-if="state.showFilterOptions" class="options"> + <t t-foreach="config.filter.options" t-as="option" t-key="option"> + <li t-on-click="selectFilter(option)"> + <t t-esc="option"></t> + </li> + </t> + </ul> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml new file mode 100644 index 00000000..f3b22b61 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ConfirmPopup.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ConfirmPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-confirm"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <main class="body"> + <t t-esc=" props.body" /> + </main> + <footer class="footer"> + <div class="button confirm" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml new file mode 100644 index 00000000..b33a5161 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/EditListInput.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="EditListInput" owl="1"> + <div> + <input type="text" t-model="props.item.text" class="popup-input list-line-input" + placeholder="Serial/Lot Number" t-on-keyup="onKeyup" /> + <i class="oe_link_icon fa fa-trash-o" role="img" aria-label="Remove" title="Remove" + t-on-click="trigger('remove-item', props.item)"></i> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml new file mode 100644 index 00000000..9b6d6354 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/EditListPopup.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="EditListPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-text"> + <header class="title"> + <t t-esc="props.title" /> + </header> + <main class="list-lines" t-on-remove-item="removeItem" + t-on-create-new-item="createNewItem"> + <t t-foreach="state.array" t-as="item" t-key="item._id"> + <EditListInput item="item" /> + </t> + </main> + <footer class="footer"> + <div class="button confirm" t-on-click="confirm"> + Ok + </div> + <div class="button cancel" t-on-click="cancel"> + Cancel + </div> + </footer> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml new file mode 100644 index 00000000..6b455fca --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ErrorBarcodePopup.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ErrorBarcodePopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-barcode"> + <header class="title drag-handle"> + <span>Unknown Barcode</span> + <br /> + <span class="barcode"> + <t t-esc="props.code" /> + </span> + </header> + <main class="body"> + <t t-esc="translatedMessage" /> + </main> + <footer class="footer"> + <div class="button cancel" t-on-click="confirm"> + Ok + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml new file mode 100644 index 00000000..0f2e19e3 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ErrorPopup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ErrorPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-error"> + <p class="title"> + <t t-esc="props.title" /> + </p> + <p class="body"> + <t t-esc="props.body" /> + </p> + <div class="footer"> + <div class="button cancel" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml new file mode 100644 index 00000000..e7552c7c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ErrorTracebackPopup.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ErrorTracebackPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-error"> + <header class="title"> + <t t-esc="props.title" /> + </header> + <main class="body traceback"> + <t t-esc="props.body" /> + </main> + <footer class="footer"> + <div t-if="!props.exitButtonIsShown" class="button cancel" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div t-if="props.exitButtonIsShown" class="button cancel" t-on-click="trigger(props.exitButtonTrigger)"> + <t t-esc="props.exitButtonText" /> + </div> + <a t-att-download="tracebackFilename" t-att-href="tracebackUrl"> + <div class="button icon download"> + <i class="fa fa-download" role="img" + aria-label="Download error traceback" + title="Download error traceback"></i> + </div> + </a> + <div class="button icon email" t-on-click="emailTraceback"> + <i class="fa fa-paper-plane" role="img" aria-label="Send by email" + title="Send by email"></i> + </div> + </footer> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml new file mode 100644 index 00000000..41d37ee5 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/NumberPopup.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="NumberPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-number" t-att-class="{ 'popup-password': props.isPassword }"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <div class="popup-input value active"> + <t t-esc="inputBuffer" /> + </div> + <div class="popup-numpad"> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('1')">1</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('2')">2</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('3')">3</button> + <t t-if="props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+1')">+1</button> + </t> + <t t-if="!props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+10')">+10</button> + </t> + <br /> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('4')">4</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('5')">5</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('6')">6</button> + <t t-if="props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+2')">+2</button> + </t> + <t t-if="!props.cheap"> + <button class="mode-button add" t-on-mousedown.prevent="sendInput('+20')">+20</button> + </t> + <br /> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('7')">7</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('8')">8</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('9')">9</button> + <button t-if="!props.isPassword" class="input-button number-char" t-on-mousedown.prevent="sendInput('-')">-</button> + <br /> + <button class="input-button numpad-char" t-on-mousedown.prevent="sendInput('Delete')">C</button> + <button class="input-button number-char" t-on-mousedown.prevent="sendInput('0')">0</button> + <button class="input-button number-char dot" t-on-mousedown.prevent="sendInput(decimalSeparator)"> + <t t-esc="decimalSeparator" /></button> + <button class="input-button numpad-backspace" t-on-mousedown.prevent="sendInput('Backspace')"> + <img style="pointer-events: none;" + src="/point_of_sale/static/src/img/backspace.png" width="24" + height="21" alt="Backspace" /> + </button> + <br /> + </div> + <footer class="footer centered"> + <div class="button cancel" t-on-mousedown.prevent="cancel"> + <t t-esc="props.cancelText" /> + </div> + <div class="button confirm" t-on-mousedown.prevent="confirm"> + <t t-esc="props.confirmText" /> + </div> + </footer> + </div> + </Draggable> + + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml new file mode 100644 index 00000000..9950bc3d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/OfflineErrorPopup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OfflineErrorPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-error"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <main class="body traceback"><t t-esc="props.body"/></main> + <footer class="footer"> + <div class="button cancel" t-on-click="cancel"> + Ok + </div> + <div class="button dont-show-again" t-on-click="dontShowAgain"> + Don't show again + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml new file mode 100644 index 00000000..b2f142b9 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/OrderImportPopup.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderImportPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-import"> + <header class="title drag-handle"> + <span>Finished Importing Orders</span> + </header> + <ul class="body"> + <li>Successfully imported <b><t t-esc="props.report.paid or 0" /></b> paid orders</li> + <li>Successfully imported <b><t t-esc="props.report.unpaid or 0" /></b> unpaid orders</li> + <t t-if="unpaidSkipped"> + <li><b><t t-esc="unpaidSkipped"/></b> unpaid orders could not be imported + <ul> + <li><b><t t-esc="props.report.unpaid_skipped_existing or 0" /></b> were duplicates of existing orders</li> + <li><b><t t-esc="props.report.unpaid_skipped_session or 0" /></b> belong to another session: + <t t-if="props.report.unpaid_skipped_sessions"> + <ul> + <li>Session ids: <b><t t-esc="props.report.unpaid_skipped_sessions" /></b></li> + </ul> + </t> + </li> + </ul> + </li> + </t> + </ul> + <footer class="footer"> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.confirmText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml new file mode 100644 index 00000000..fb863754 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductConfiguratorPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-text popup-lg product-configurator-popup"> + <header class="title"> + <t t-esc="props.product.display_name" /> + </header> + + <main class="body product_configurator_attributes col-lg-4 col-md-6 col-sm-12"> + <div t-foreach="props.attributes" t-as="attribute" class="attribute"> + <div class="attribute_name" t-esc="attribute.name"/> + <RadioProductAttribute t-if="attribute.display_type === 'radio'" attribute="attribute"/> + <SelectProductAttribute t-elif="attribute.display_type === 'select'" attribute="attribute"/> + <ColorProductAttribute t-elif="attribute.display_type === 'color'" attribute="attribute"/> + </div> + </main> + + <footer class="footer"> + <div class="button highlight confirm" t-on-click="confirm"> + Add + </div> + <div class="button cancel" t-on-click="cancel"> + Cancel + </div> + </footer> + </div> + </div> + </t> + + <t t-name="RadioProductAttribute" owl="1"> + <div class="configurator_radio"> + <div t-foreach="values" t-as="value"> + <input type="radio" t-model="state.selected_value" t-att-name="attribute.id" + t-attf-id="{{ attribute.id }}_{{ value.id }}" t-att-value="value.id"/> + + <label t-attf-for="{{ attribute.id }}_{{ value.id }}"> + <div class="radio_attribute_label"> + <t t-esc="value.name"/> + <span t-if="value.price_extra" class="price_extra"> + + <t t-esc="env.pos.format_currency(value.price_extra)"/> + </span> + </div> + + <t t-if="value.id == state.selected_value && value.is_custom"> + <input class="custom_value" type="text" t-model="state.custom_value"/> + </t> + </label> + </div> + </div> + </t> + + <t t-name="SelectProductAttribute" owl="1"> + <div> + <t t-set="is_custom" t-value="false"/> + + <select class="configurator_select" t-model="state.selected_value"> + <option t-foreach="values" t-as="value" t-att-value="value.id"> + <t t-set="is_custom" t-value="is_custom || (value.is_custom && value.id == state.selected_value)"/> + <t t-esc="value.name"/> + <t t-if="value.price_extra"> + + <t t-esc="env.pos.format_currency(value.price_extra)"/> + </t> + </option> + </select> + + <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/> + </div> + </t> + + <t t-name="ColorProductAttribute" owl="1"> + <div> + <t t-set="is_custom" t-value="false"/> + + <ul class="color_attribute_list"> + <li t-foreach="values" t-as="value" class="color_attribute_list_item"> + <t t-set="is_custom" t-value="is_custom || (value.is_custom && value.id == state.selected_value)"/> + <label t-attf-class="configurator_color {{ value.id == state.selected_value ? 'active' : '' }}" + t-attf-style="background-color: {{ value.html_color }};" t-att-data-color="value.name"> + <input type="radio" t-model="state.selected_value" t-att-value="value.id" t-att-name="attribute.id"/> + </label> + </li> + </ul> + + <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml new file mode 100644 index 00000000..b9ca1bc7 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/SelectionPopup.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SelectionPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-selection"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <div class="selection scrollable-y"> + <t t-foreach="props.list" t-as="item" t-key="item.id"> + <div class="selection-item" t-att-class="{ selected: item.isSelected }" + t-on-click="selectItem(item.id)"> + <t t-esc="item.label" /> + </div> + </t> + </div> + <footer class="footer"> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml new file mode 100644 index 00000000..a142c995 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/TextAreaPopup.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TextAreaPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <Draggable> + <div class="popup popup-textarea"> + <header class="title drag-handle"> + <t t-esc="props.title" /> + </header> + <textarea t-model="state.inputValue" t-ref="input"></textarea> + <footer class="footer"> + <div class="button confirm" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </footer> + </div> + </Draggable> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml new file mode 100644 index 00000000..cc28d706 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Popups/TextInputPopup.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TextInputPopup" owl="1"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-textinput"> + <header class="title"> + <t t-esc="props.title" /> + </header> + <div class="div"> + <p> + <t t-esc="props.body" /> + </p> + <input type="text" t-model="state.inputValue" t-ref="input" /> + </div> + <div class="footer"> + <div class="button confirm" t-on-click="confirm"> + <t t-esc="props.confirmText" /> + </div> + <div class="button cancel" t-on-click="cancel"> + <t t-esc="props.cancelText" /> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml new file mode 100644 index 00000000..cce9616e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/SaleDetailsReport.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SaleDetailsReport" owl="1"> + <div class="pos-receipt"> + <t t-if="pos.company_logo_base64"> + <img class="pos-receipt-logo" t-att-src="pos.company_logo_base64" alt="Logo"/> + <br/> + </t> + <t t-if="!pos.company_logo_base64" class="pos-receipt-center-align"> + <h1 t-esc="pos.company.name" /> + <br/> + </t> + <br /><br /> + + <div class="orderlines"> + <t t-foreach="products" t-as="line" t-key="line.product_id"> + <div> + <t t-esc="line.product_name.substr(0,20)" /> + <span class="pos-receipt-right-align"> + <t t-esc="Math.round(line.quantity * Math.pow(10, pos.dp['Product Unit of Measure'])) / Math.pow(10, pos.dp['Product Unit of Measure'])" /> + <t t-if="line.uom !== 'Units'"> + <t t-esc="line.uom" /> + </t> + x + <t t-esc="pos.format_currency_no_symbol(line.price_unit)" /> + </span> + </div> + <t t-if="line.discount !== 0"> + <div class="pos-receipt-left-padding">Discount: <t t-esc="line.discount" />%</div> + </t> + </t> + </div> + + <br/> + <div>------------------------</div> + <br/> + + <div> + Payments: + </div> + <div t-foreach="payments" t-as="payment"> + <t t-esc="payment.name" /> + <span t-esc="pos.format_currency_no_symbol(payment.total)" class="pos-receipt-right-align"/> + </div> + + <br/> + <div>------------------------</div> + <br/> + + <div> + Taxes: + </div> + <div t-foreach="taxes" t-as="tax"> + <t t-esc="tax.name" /> + <span t-esc="pos.format_currency_no_symbol(tax.tax_amount)" class="pos-receipt-right-align"/> + </div> + + <br/> + <div>------------------------</div> + <br/> + + <div> + Total: + <span t-esc="pos.format_currency_no_symbol(total_paid)" class="pos-receipt-right-align"/> + </div> + + <br/> + <div class="pos-receipt-order-data"> + <div><t t-esc="date" /></div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml new file mode 100644 index 00000000..5699dc64 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientDetailsEdit" owl="1"> + <section class="client-details edit"> + <div class="client-picture"> + <t t-if="partnerImageUrl"> + <img t-att-src="partnerImageUrl" alt="Partner" + style="width: 64px; height: 64px; object-fit: cover;" /> + </t> + <t t-else=""> + <i class="fa fa-camera" role="img" aria-label="Picture" title="Picture"></i> + </t> + <input type="file" class="image-uploader" t-on-change="uploadImage" /> + </div> + <input class="detail client-name" name="name" t-att-value="props.partner.name" + placeholder="Name" t-on-change="captureChange" /> + <div class="client-details-box clearfix"> + <div class="client-details-left"> + <div class="client-detail"> + <span class="label">Street</span> + <input class="detail client-address-street" name="street" + t-on-change="captureChange" t-att-value="props.partner.street || ''" + placeholder="Street" /> + </div> + <div class="client-detail"> + <span class="label">City</span> + <input class="detail client-address-city" name="city" + t-on-change="captureChange" t-att-value="props.partner.city || ''" + placeholder="City" /> + </div> + <div class="client-detail"> + <span class="label">Postcode</span> + <input class="detail client-address-zip" name="zip" + t-on-change="captureChange" t-att-value="props.partner.zip || ''" + placeholder="ZIP" /> + </div> + <div class="client-detail"> + <span class="label">State</span> + <select class="detail client-address-states needsclick" name="state_id" + t-on-change="captureChange"> + <option value="">None</option> + <t t-foreach="env.pos.states" t-as="state" t-key="state.id"> + <option t-if="props.partner.country_id[0] == state.country_id[0]" + t-att-value="state.id" + t-att-selected="props.partner.state_id ? ((state.id === props.partner.state_id[0]) ? true : undefined) : undefined"> + <t t-esc="state.name" /> + </option> + </t> + </select> + </div> + <div class="client-detail"> + <span class="label">Country</span> + <select class="detail client-address-country needsclick" name="country_id" + t-on-change="captureChange"> + <option value="">None</option> + <t t-foreach="env.pos.countries" t-as="country" t-key="country.id"> + <option t-att-value="country.id" + t-att-selected="props.partner.country_id ? ((country.id === props.partner.country_id[0]) ? true : undefined) : undefined"> + <t t-esc="country.name" /> + </option> + </t> + </select> + </div> + </div> + <div class="client-details-right"> + <div class="client-detail"> + <span class="label">Language</span> + <select class="detail client-lang needsclick" name="lang" + t-on-change="captureChange"> + <t t-foreach="env.pos.langs" t-as="lang" t-key="lang.id"> + <option t-att-value="lang.code" + t-att-selected="props.partner.lang ? ((lang.code === props.partner.lang) ? true : undefined) : lang.code === env.pos.user.lang? true : undefined"> + <t t-esc="lang.name" /> + </option> + </t> + </select> + </div> + <div class="client-detail"> + <span class="label">Email</span> + <input class="detail client-email" name="email" type="email" + t-on-change="captureChange" + t-att-value="props.partner.email || ''" /> + </div> + <div class="client-detail"> + <span class="label">Phone</span> + <input class="detail client-phone" name="phone" type="tel" + t-on-change="captureChange" + t-att-value="props.partner.phone || ''" /> + </div> + <div class="client-detail"> + <span class="label">Barcode</span> + <input class="detail barcode" name="barcode" t-on-change="captureChange" + t-att-value="props.partner.barcode || ''" /> + </div> + <div class="client-detail"> + <span class="label">Tax ID</span> + <input class="detail vat" name="vat" t-on-change="captureChange" + t-att-value="props.partner.vat || ''" /> + </div> + <div t-if="env.pos.pricelists.length gt 1" class="client-detail"> + <span class="label">Pricelist</span> + <select class="detail needsclick" name="property_product_pricelist" + t-on-change="captureChange"> + <t t-foreach="env.pos.pricelists" t-as="pricelist" + t-key="pricelist.id"> + <option t-att-value="pricelist.id" + t-att-selected="props.partner.property_product_pricelist ? (pricelist.id === props.partner.property_product_pricelist[0] ? true : false) : false"> + <t t-esc="pricelist.display_name" /> + </option> + </t> + </select> + </div> + </div> + </div> + </section> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml new file mode 100644 index 00000000..7693f08c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientLine.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientLine" owl="1"> + <tr t-attf-class="client-line {{highlight}}" t-att-data-id="props.partner.id" + t-on-click="trigger('click-client', {client: props.partner})"> + <td> + <t t-esc="props.partner.name" /> + <span t-if="highlight"> + <br/><button class="edit-client-button" t-on-click.stop="trigger('click-edit')">EDIT</button> + </span> + </td> + <td t-if="!env.isMobile"> + <t t-esc="props.partner.address" /> + </td> + <td t-if="!env.isMobile" style="width: 130px;"> + <t t-esc="props.partner.phone || ''" /> + </td> + <td t-if="env.isMobile"> + <t t-esc="props.partner.zip or ''" /> + <span t-if="highlight"><br/></span> + </td> + <td> + <t t-esc="props.partner.email or ''" /> + <span t-if="highlight"><br/></span> + </td> + </tr> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml new file mode 100644 index 00000000..baefab13 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ClientListScreen" owl="1"> + <div class="clientlist-screen screen" t-on-activate-edit-mode="activateEditMode"> + <div class="screen-content"> + <div class="top-content"> + <div t-if="!state.detailIsShown && !state.selectedClient" class="button new-customer" role="img" aria-label="Add a customer" + t-on-click="trigger('activate-edit-mode', { isNewClient: true })" + title="Add a customer"> + <t t-if="!env.isMobile"> + Create + </t> + <t t-else=""> + <i class="fa fa-plus"></i> + </t> + </div> + <div t-if="isNextButtonVisible" t-on-click="clickNext" + class="button next highlight"> + <t t-if="!env.isMobile"> + <t t-esc="nextButton.text" /> + </t> + <t t-else=""> + <i t-if="nextButton.command === 'deselect'" class="fa fa-trash"></i> + <i t-if="nextButton.command === 'set'" class="fa fa-check"></i> + </t> + </div> + <div class="button" t-if="state.detailIsShown" t-on-click="trigger('click-save')"> + <t t-if="!env.isMobile"> + <i class="fa fa-floppy-o"/> + <span> Save</span> + </t> + <t t-else=""> + <i class="fa fa-floppy-o"/> + </t> + </div> + <div class="button back" t-on-click="back"> + <t t-if="!env.isMobile">Discard</t> + <t t-else=""> + <i class="fa fa-undo"></i> + </t> + </div> + <div t-if="!state.detailIsShown" class="searchbox-client top-content-center"> + <input placeholder="Search Customers" size="1" t-on-keyup="updateClientList" /> + <span class="search-clear-client"></span> + </div> + </div> + <section class="full-content"> + <div class="client-window"> + <section class="subwindow collapsed"> + <div class="subwindow-container collapsed"> + <div t-if="state.detailIsShown" class="client-details-contents subwindow-container-fix"> + <ClientDetailsEdit t-props="state.editModeProps" + t-on-cancel-edit="cancelEdit"/> + </div> + </div> + </section> + <section class="subwindow list"> + <div class="subwindow-container"> + <div t-if="!state.detailIsShown" class="subwindow-container-fix scrollable-y"> + <table class="client-list"> + <thead> + <tr> + <th>Name</th> + <th t-if="!env.isMobile">Address</th> + <th t-if="!env.isMobile">Phone</th> + <th t-if="env.isMobile">ZIP</th> + <th>Email</th> + </tr> + </thead> + <tbody class="client-list-contents"> + <t t-foreach="clients" t-as="partner" + t-key="partner.id"> + <ClientLine partner="partner" + selectedClient="state.selectedClient" + detailIsShown="state.detailIsShown" + t-on-click-client="clickClient" /> + </t> + </tbody> + </table> + </div> + </div> + </section> + </div> + </section> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml new file mode 100644 index 00000000..72c85188 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="InvoiceButton" owl="1"> + <div class="control-button" t-att-class="{ highlight: isHighlighted }"> + <i class="fa fa-file-pdf-o"></i> + <span> </span> + <span><t t-esc="commandName"></t></span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml new file mode 100644 index 00000000..df3e4e06 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ReprintReceiptButton" owl="1"> + <div class="control-button"> + <i class="fa fa-print"></i> + <span> </span> + <span>Print Receipt</span> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml new file mode 100644 index 00000000..479b35a4 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <div t-name="MobileOrderManagementScreen" class="screen-full-width" owl="1"> + <div t-if="mobileState.showDetails" class="leftpane"> + <OrderDetails order="orderManagementContext.selectedOrder" /> + <div class="pads"> + <div class="control-buttons"> + <t t-foreach="controlButtons" t-as="cb" t-key="cb.name"> + <t t-component="cb.component" t-key="cb.name" /> + </t> + </div> + <div class="subpads"> + <ActionpadWidget client="selectedClient" /> + <NumpadWidget /> + </div> + </div> + <div class="back-to-list" t-on-click="mobileState.showDetails = false"> + <span>Back to list</span> + </div> + </div> + <div t-else="" class="rightpane"> + <div class="flex-container"> + <OrderManagementControlPanel /> + <OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" /> + </div> + </div> + </div> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml new file mode 100644 index 00000000..87579d09 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderDetails" owl="1"> + <div class="order-container"> + <div t-ref="scrollable" class="order-scroller touch-scrollable"> + <div class="order"> + <t t-if="!props.order"> + <div class="order-empty"> + <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart" + title="Shopping cart" /> + <h1>Select an order</h1> + </div> + </t> + <t t-elif="orderlines.length === 0"> + <div class="order-empty"> + <i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart" + title="Shopping cart" /> + <h1>Order is empty</h1> + </div> + </t> + <t t-else=""> + <ul class="orderlines"> + <t t-foreach="orderlines" t-as="orderline" t-key="orderline.id"> + <OrderlineDetails line="orderline" /> + </t> + </ul> + <OrderSummary total="total" tax="tax" /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml new file mode 100644 index 00000000..865f609b --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderList.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderList" owl="1"> + <div class="orders"> + <div class="order-row header"> + <div class="header name">Order</div> + <div class="header date">Date</div> + <div class="header customer">Customer</div> + <div class="header total">Total</div> + </div> + <div class="order-list"> + <t t-foreach="props.orders" t-as="order" t-key="order.cid"> + <OrderRow order="order" highlightedOrder="highlightedOrder" /> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml new file mode 100644 index 00000000..3a294bfd --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderManagementControlPanel" owl="1"> + <div class="control-panel"> + <div class="item button back" t-on-click="trigger('close-screen')"> + <i class="fa fa-angle-double-left"></i> + <span> Back</span> + </div> + <div class="item search-box"> + <span class="icon"> + <i class="fa fa-search" /> + </span> + <input type="text" t-model="orderManagementContext.searchString" t-on-keydown="onInputKeydown" placeholder="E.g. customer: Steward, date: 2020-05-09" /> + <span class="clear" t-on-click="trigger('clear-search')"> + <i class="fa fa-remove" /> + </span> + </div> + <div t-if="showPageControls" class="item"> + <div class="page-controls"> + <div class="previous" t-on-click="trigger('prev-page')"> + <i class="fa fa-fw fa-caret-left" role="img" aria-label="Previous Order List" title="Previous Order List"></i> + </div> + <div class="next" t-on-click="trigger('next-page')"> + <i class="fa fa-fw fa-caret-right" role="img" aria-label="Next Order List" title="Next Order List"></i> + </div> + </div> + <div class="page"> + <span><t t-esc="pageNumber" /></span> + </div> + </div> + <div t-else="" class="item"></div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml new file mode 100644 index 00000000..8992e2c8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderManagementScreen" owl="1"> + <div class="order-management-screen screen" t-att-class="{ oe_hidden: !props.isShown }"> + <div t-if="!env.isMobile" class="screen-full-width"> + <div class="leftpane"> + <OrderDetails order="orderManagementContext.selectedOrder" /> + <div class="pads"> + <div class="control-buttons"> + <t t-foreach="controlButtons" t-as="cb" t-key="cb.name"> + <t t-component="cb.component" t-key="cb.name" /> + </t> + </div> + <div class="subpads"> + <ActionpadWidget client="selectedClient" /> + <NumpadWidget /> + </div> + </div> + </div> + <div class="rightpane"> + <div class="flex-container"> + <OrderManagementControlPanel /> + <OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" /> + </div> + </div> + </div> + <MobileOrderManagementScreen t-else="" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml new file mode 100644 index 00000000..29b07cfe --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderRow.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderRow" owl="1"> + <div class="order-row" + t-att-class="{ highlight: highlighted, lighter: !props.order.locked }" + t-on-click="trigger('click-order', props.order)"> + <div class="item name"><t t-esc="name" /></div> + <div class="item date"><t t-esc="date" /></div> + <div class="item customer"><t t-esc="customer" /></div> + <div class="item total"><t t-esc="total" /></div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml new file mode 100644 index 00000000..2e6869e5 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderlineDetails" owl="1"> + <li class="orderline"> + <span class="product-name"> + <t t-esc="productName" /> + </span> + <span class="price"> + <t t-esc="totalPrice" /> + </span> + <li class="info"> + <strong> + <t t-esc="quantity" /> + </strong> + <span><t t-esc="pricePerUnit" /></span> + </li> + </li> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml new file mode 100644 index 00000000..0a80a0e0 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ReprintReceiptScreen" owl="1"> + <div class="receipt-screen screen"> + <div class="screen-content"> + <div class="top-content"> + <span class="button back" t-on-click="confirm"> + <i class="fa fa-angle-double-left"></i> + <span> </span> + <span>Back</span> + </span> + </div> + <div class="centered-content"> + <div class="button print" t-on-click="tryReprint"> + <i class="fa fa-print"></i> Print Receipt + </div> + <div class="pos-receipt-container"> + <OrderReceipt order="props.order" t-ref="order-receipt" /> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml new file mode 100644 index 00000000..381cd88c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PSNumpadInputButton" owl="1"> + <button t-attf-class="{{ _class }}" + t-on-click="trigger('input-from-numpad', { key: props.value })"> + <t t-slot="default"> + <t t-if="props.text"> + <t t-esc="props.text" /> + </t> + <t t-else=""> + <t t-esc="props.value" /> + </t> + </t> + </button> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml new file mode 100644 index 00000000..dacf7e96 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentMethodButton.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentMethodButton" owl="1"> + <div class="button paymentmethod" + t-on-click="trigger('new-payment-line', props.paymentMethod)"> + <div class="payment-name"> + <t t-esc="props.paymentMethod.name" /> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml new file mode 100644 index 00000000..d78128c9 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreen.xml @@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreen" owl="1"> + <div class="payment-screen screen" t-att-class="{ oe_hidden: !props.isShown }"> + <div class="screen-content"> + <t t-if="!env.isMobile"> + <div class="top-content"> + <div class="button back" + t-on-click="showScreen('ProductScreen')"> + <i class="fa fa-angle-double-left fa-fw"></i> + <span class="back_text">Back</span> + </div> + <div class="top-content-center"><h1>Payment</h1></div> + <div class="button next" t-att-class="{ highlight: currentOrder.is_paid() }" + t-on-click="validateOrder(false)"> + <span class="next_text">Validate</span> + <i class="fa fa-angle-double-right fa-fw"></i> + </div> + </div> + </t> + <div class="main-content"> + <div class="left-content"> + <t t-if="env.isMobile"> + <section class="paymentlines-container"> + <PaymentScreenStatus paymentLines="paymentLines" /> + </section> + </t> + <div class="paymentmethods-container"> + <PaymentScreenPaymentLines paymentLines="paymentLines" /> + <div class="paymentmethods"> + <t t-foreach="payment_methods_from_config" t-as="paymentMethod" + t-key="paymentMethod.id"> + <PaymentMethodButton paymentMethod="paymentMethod" /> + </t> + </div> + </div> + </div> + <div class="right-content"> + <t t-if="!env.isMobile"> + <section class="paymentlines-container"> + <PaymentScreenStatus paymentLines="paymentLines" /> + </section> + </t> + + <div class="payment-buttons-container"> + <section class="payment-numpad"> + <PaymentScreenNumpad /> + </section> + + <div class="payment-buttons"> + <div class="customer-button"> + <div class="button" t-on-click="selectClient"> + <i class="fa fa-user" role="img" aria-label="Customer" + title="Customer" /> + <span class="js_customer_name"> + <t t-if="env.pos.get_client()"> + <t t-esc="env.pos.get_client().name" /> + </t> + <t t-if="!env.pos.get_client()"> + Customer + </t> + </span> + </div> + </div> + <div class="payment-controls"> + <div t-if="env.pos.config.module_account" class="button js_invoice" + t-att-class="{ highlight: currentOrder.is_to_invoice() }" + t-on-click="toggleIsToInvoice"> + <i class="fa fa-file-text-o" /> Invoice + </div> + <div t-if="env.pos.config.tip_product_id" class="button js_tip" + t-on-click="addTip"> + <i class="fa fa-heart" /> Tip + </div> + <div t-if="env.pos.config.iface_cashdrawer" class="button js_cashdrawer" + t-on-click="openCashbox"> + <i class="fa fa-archive" /> Open Cashbox + </div> + </div> + </div> + </div> + </div> + </div> + <t t-if="env.isMobile"> + <div class="switchpane"> + <button class="btn-switchpane" t-att-class="{ secondary: !currentOrder.is_paid() }" t-on-click="validateOrder(false)"> + <h1>Validate</h1> + </button> + <button class="btn-switchpane secondary" t-on-click="showScreen('ProductScreen', {mobile_pane: 'left'})"> + <h1>Review</h1> + </button> + </div> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml new file mode 100644 index 00000000..792c490c --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreenElectronicPayment" owl="1"> + <div class="paymentline electronic_payment"> + <t t-if="props.line.payment_status == 'pending'"> + <div> + Payment request pending + </div> + <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)"> + Send + </div> + </t> + <t t-elif="props.line.payment_status == 'retry'"> + <div> + Transaction cancelled + </div> + <div class="button send_payment_request highlight" title="Send Payment Request" t-on-click="trigger('send-payment-request', props.line)"> + Retry + </div> + </t> + <t t-elif="props.line.payment_status == 'force_done'"> + <div> + Connection error + </div> + <div class="button send_force_done" title="Force Done" t-on-click="trigger('send-force-done', props.line)"> + Force done + </div> + </t> + <t t-elif="props.line.payment_status == 'waitingCard'"> + <div> + Waiting for card + </div> + <div class="button send_payment_cancel" title="Cancel Payment Request" t-on-click="trigger('send-payment-cancel', props.line)"> + Cancel + </div> + </t> + <t t-elif="['waiting', 'waitingCancel'].includes(props.line.payment_status)"> + <div> + Request sent + </div> + <div> + <i class="fa fa-spinner fa-spin" role="img" /> + </div> + </t> + <t t-elif="props.line.payment_status == 'reversing'"> + <div> + Reversal request sent to terminal + </div> + <div> + <i class="fa fa-spinner fa-spin" role="img" /> + </div> + </t> + <t t-elif="props.line.payment_status == 'done'"> + <div> + Payment Successful + </div> + <t t-if="props.line.can_be_reversed"> + <div class="button send_payment_reversal" title="Reverse Payment" t-on-click="trigger('send-payment-reverse', props.line)"> + Reverse + </div> + </t> + <t t-else=""> + <div></div> + </t> + </t> + <t t-elif="props.line.payment_status == 'reversed'"> + <div> + Payment reversed + </div> + <div></div> + </t> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml new file mode 100644 index 00000000..d988ab5f --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreenNumpad" owl="1"> + <div class="numpad"> + <PSNumpadInputButton value="'1'" /> + <PSNumpadInputButton value="'2'" /> + <PSNumpadInputButton value="'3'" /> + <PSNumpadInputButton value="'+10'" changeClassTo="'mode-button'" /> + <br /> + <PSNumpadInputButton value="'4'" /> + <PSNumpadInputButton value="'5'" /> + <PSNumpadInputButton value="'6'" /> + <PSNumpadInputButton value="'+20'" changeClassTo="'mode-button'" /> + <br /> + <PSNumpadInputButton value="'7'" /> + <PSNumpadInputButton value="'8'" /> + <PSNumpadInputButton value="'9'" /> + <PSNumpadInputButton value="'+50'" changeClassTo="'mode-button'" /> + <br /> + <PSNumpadInputButton value="'-'" text="'+/-'" /> + <PSNumpadInputButton value="'0'" /> + <PSNumpadInputButton value="decimalPoint" /> + <PSNumpadInputButton value="'Backspace'"> + <img src="/point_of_sale/static/src/img/backspace.png" width="24" height="21" + alt="Backspace" /> + </PSNumpadInputButton> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml new file mode 100644 index 00000000..6816f300 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenPaymentLines.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="PaymentScreenPaymentLines" owl="1"> + <div class="paymentlines"> + <t t-foreach="props.paymentLines" t-as="line" t-key="line.cid"> + <t t-if="line.selected"> + <div class="paymentline selected" + t-att-class="selectedLineClass(line)" + t-on-click="trigger('select-payment-line', { cid: line.cid })"> + <div class="payment-name"> + <t t-esc="line.payment_method.name" /> + </div> + <div class="payment-amount"> + <t t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)"> + <t t-esc="env.pos.format_currency_no_symbol(line.get_amount())" /> + </t> + <t t-else=""> + <t t-esc="formatLineAmount(line)" /> + </t> + </div> + <t t-if="!line.payment_status or !['done', 'reversed'].includes(line.payment_status)"> + <div class="delete-button" + t-on-click="trigger('delete-payment-line', { cid: line.cid })" + aria-label="Delete" title="Delete"> + <i class="fa fa-times-circle" /> + </div> + </t> + </div> + <t t-if="line and line.payment_status"> + <PaymentScreenElectronicPayment line="line" /> + </t> + </t> + <t t-else=""> + <div class="paymentline" + t-att-class="unselectedLineClass(line)" + t-on-click="trigger('select-payment-line', { cid: line.cid })"> + <div class="payment-name"> + <t t-esc="line.payment_method.name" /> + </div> + <div class="payment-amount"> + <t t-if="line and line.payment_status and ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)"> + <t t-esc="env.pos.format_currency_no_symbol(line.get_amount())" /> + </t> + <t t-else=""> + <t t-esc="formatLineAmount(line)" /> + </t> + </div> + <t t-if="!line.payment_status or !['done', 'reversed'].includes(line.payment_status)"> + <div class="delete-button" + t-on-click="trigger('delete-payment-line', { cid: line.cid })" + aria-label="Delete" title="Delete"> + <i class="fa fa-times-circle" /> + </div> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml new file mode 100644 index 00000000..7c90c8a5 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/PaymentScreen/PaymentScreenStatus.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-name="PaymentScreenStatus" owl="1"> + <div t-if="props.paymentLines.length === 0" class="paymentlines-empty"> + <div class="total"> + <t t-esc="totalDueText" /> + </div> + <div class="message"> + Please select a payment method. + </div> + </div> + + <div t-else=""> + <div class="payment-status-container"> + <div> + <div class="payment-status-remaining"> + <span class="label">Remaining</span> + <span class="amount" + t-att-class="{ highlight: currentOrder.get_due() > 0 }"> + <t t-esc="remainingText" /> + </span> + </div> + <div class="payment-status-total-due"> + <span class="label">Total Due</span> + <span> + <t t-esc="totalDueText" /> + </span> + </div> + </div> + <div> + <div class="payment-status-change"> + <span class="label">Change</span> + <span class="amount" + t-att-class="{ highlight: currentOrder.get_change() > 0 }"> + <t t-esc="changeText" /> + </span> + </div> + </div> + </div> + </div> +</t> +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml new file mode 100644 index 00000000..183912fd --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ActionpadWidget.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ActionpadWidget" owl="1"> + <div class="actionpad"> + <button class="button set-customer" t-att-class="{'decentered': isLongName}" + t-on-click="trigger('click-customer')"> + <t t-if="!env.isMobile"><i class="fa fa-user" role="img" aria-label="Customer" title="Customer" /></t> + <t t-if="client"> + <t t-esc="client.name" /> + </t> + <t t-else=""> + Customer + </t> + </button> + <button class="button pay" t-on-click="trigger('click-pay')"> + <div class="pay-circle"> + <i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" /> + </div> + Payment + </button> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml new file mode 100644 index 00000000..27193bf9 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CashBoxOpening.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CashBoxOpening" owl="1"> + <div style="margin-top: -20px;"> + <br/> + <h1> + <span> + Pos closed. + </span><br/> + <span> + Set a cash opening + </span> + + </h1> + <h1>Opening amount:</h1> + <input name="cashBoxValue" class="cashbox-input" t-on-change="captureChange" t-att-value="defaultValue"/> + <span class="currencyCashBox" t-esc="symbol"/> + + <h1>Notes:</h1> + <textarea name="notes" style="width: 51%" t-on-change="captureChange"/><br/><br/> + + <span class="control-button" t-on-click="startSession()">Open</span> + + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml new file mode 100644 index 00000000..0e9ba155 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryBreadcrumb.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CategoryBreadcrumb" owl="1"> + <span class="breadcrumb"> + <img src="/point_of_sale/static/src/img/bc-arrow-big.png" class="breadcrumb-arrow" + alt="Slash" /> + <span class="breadcrumb-button" + t-on-click="trigger('switch-category', props.category.id)"> + <t t-esc="props.category.name"></t> + </span> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml new file mode 100644 index 00000000..da829cba --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategoryButton.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CategoryButton" owl="1"> + <span class="category-button" t-on-click="trigger('switch-category', props.category.id)"> + <div class="category-img"> + <img t-att-src="imageUrl" alt="Category" /> + </div> + <div class="category-name"> + <t t-esc="props.category.name" /> + </div> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml new file mode 100644 index 00000000..de052e16 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/CategorySimpleButton.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="CategorySimpleButton" owl="1"> + <span class="category-simple-button" + t-on-click="trigger('switch-category', props.category.id)"> + <t t-esc="props.category.name" /> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml new file mode 100644 index 00000000..ab09f363 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SetFiscalPositionButton" owl="1"> + <div class="control-button o_fiscal_position_button"> + <i class="fa fa-book" role="img" aria-label="Set fiscal position" + title="Set fiscal position" /> + <t t-esc='currentFiscalPositionName' /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml new file mode 100644 index 00000000..ffe3e3ec --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="SetPricelistButton" owl="1"> + <div class="control-button o_pricelist_button"> + <i class="fa fa-th-list" role="img" aria-label="Price list" title="Price list" /> + <t t-esc="currentPricelistName" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml new file mode 100644 index 00000000..2bfa426e --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/HomeCategoryBreadcrumb.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="HomeCategoryBreadcrumb" owl="1"> + <span class="breadcrumb"> + <span t-if="!env.isMobile" class="breadcrumb-button breadcrumb-home" + t-on-click="trigger('switch-category', 0)"> + <i class="fa fa-home" role="img" aria-label="Home" title="Home"></i> + </span> + <span t-if="env.isMobile" class="breadcrumb-button breadcrumb-home" + t-on-click="trigger('categ-popup', props.subcategories)"> + <t t-if="env.pos.get('selectedCategoryId') === 0"> + All + </t> + <t t-else=""> + <t t-esc="props.currentCat.name"/> + </t> + </span> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml new file mode 100644 index 00000000..4b9962c0 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/NumpadWidget.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="NumpadWidget" owl="1"> + <div class="numpad"> + <button class="input-button number-char" t-on-click="sendInput('1')">1</button> + <button class="input-button number-char" t-on-click="sendInput('2')">2</button> + <button class="input-button number-char" t-on-click="sendInput('3')">3</button> + <button class="mode-button" t-att-class="{'selected-mode': props.activeMode === 'quantity'}" + t-on-click="changeMode('quantity')">Qty</button> + <br /> + <button class="input-button number-char" t-on-click="sendInput('4')">4</button> + <button class="input-button number-char" t-on-click="sendInput('5')">5</button> + <button class="input-button number-char" t-on-click="sendInput('6')">6</button> + <button class="mode-button" t-att-class="{ + 'selected-mode': props.activeMode === 'discount', + 'disabled-mode': !hasManualDiscount + }" + t-att-disabled="!hasManualDiscount" + t-on-click="changeMode('discount')">Disc</button> + <br /> + <button class="input-button number-char" t-on-click="sendInput('7')">7</button> + <button class="input-button number-char" t-on-click="sendInput('8')">8</button> + <button class="input-button number-char" t-on-click="sendInput('9')">9</button> + <button class="mode-button" t-att-class="{ + 'selected-mode': props.activeMode === 'price', + 'disabled-mode': !hasPriceControlRights + }" t-att-disabled="!hasPriceControlRights" + t-on-click="changeMode('price')">Price</button> + <br /> + <button class="input-button numpad-minus" t-on-click="sendInput('-')">+/-</button> + <button class="input-button number-char" t-on-click="sendInput('0')">0</button> + <button class="input-button number-char" t-on-click="sendInput(decimalSeparator)"> + <t t-esc="decimalSeparator" /> + </button> + <button class="input-button numpad-backspace" t-on-click="sendInput('Backspace')"> + <img style="pointer-events: none;" src="/point_of_sale/static/src/img/backspace.png" + width="24" height="21" alt="Backspace" /> + </button> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml new file mode 100644 index 00000000..a229c53a --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderSummary.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderSummary" owl="1"> + <div class="summary clearfix"> + <div class="line"> + <div class="entry total"> + <span class="badge">Total: </span> + <span class="value"> + <t t-esc="props.total" /> + </span> + <div t-if="props.tax" class="subentry"> + Taxes: + <span class="value"> + <t t-esc="props.tax" /> + </span> + </div> + </div> + </div> + </div> + </t> + +</templates>
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml new file mode 100644 index 00000000..532309dc --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/OrderWidget.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderWidget" owl="1"> + <div class="order-container" t-ref="scrollable"> + <div class="order"> + <t t-if="orderlinesArray.length === 0" > + <div class='order-empty'> + <i class='fa fa-shopping-cart' role="img" aria-label="Shopping cart" + title="Shopping cart"/> + <h1>This order is empty</h1> + </div> + </t> + <t t-else=""> + <ul class="orderlines"> + <t t-foreach="orderlinesArray" t-as="orderline" t-key="orderline.id"> + <Orderline line="orderline" /> + </t> + </ul> + <OrderSummary total="state.total" tax="state.tax" /> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml new file mode 100644 index 00000000..e4ede636 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="Orderline" owl="1"> + <li t-on-click="selectLine" class="orderline" t-att-class="addedClasses"> + <span class="product-name"> + <t t-esc="props.line.get_full_product_name()"/> + <span> </span> + <t t-if="props.line.get_product().tracking!=='none' && (env.pos.picking_type.use_create_lots || env.pos.picking_type.use_existing_lots)"> + <t t-if="props.line.has_valid_product_lot()"> + <i t-on-click.stop="lotIconClicked" + class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_green" + aria-label="Valid product lot" + role="img" + title="Valid product lot" + /> + </t> + <t t-else=""> + <i t-on-click.stop="lotIconClicked" + class="oe_link_icon fa fa-list oe_icon line-lot-icon oe_red" + aria-label="Invalid product lot" + role="img" + title="Invalid product lot" + /> + </t> + </t> + </span> + <span class="price"> + <t t-esc="env.pos.format_currency(props.line.get_display_price())"/> + </span> + <ul class="info-list"> + <t t-if="props.line.get_quantity_str() !== '1' || props.line.selected "> + <li class="info"> + <em> + <t t-esc="props.line.get_quantity_str()" /> + </em> + <span> </span><t t-esc="props.line.get_unit().name" /> + at + <t t-if="props.line.display_discount_policy() == 'without_discount' and + props.line.get_unit_display_price() < props.line.get_lst_price()"> + <s> + <t t-esc="env.pos.format_currency(props.line.get_fixed_lst_price(),'Product Price')" /> + </s> + <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" /> + </t> + <t t-else=""> + <t t-esc="env.pos.format_currency(props.line.get_unit_display_price(),'Product Price')" /> + </t> + / + <t t-esc="props.line.get_unit().name" /> + </li> + </t> + <t t-if="props.line.get_discount_str() !== '0'"> + <li class="info"> + With a + <em> + <t t-esc="props.line.get_discount_str()" />% + </em> + discount + </li> + </t> + </ul> + <t t-if="props.line.get_lot_lines()"> + <ul class="info-list"> + <t t-foreach="props.line.get_lot_lines()" t-as="lot" t-key="lot.cid"> + <li> + SN <t t-esc="lot.attributes['lot_name']"/> + </li> + </t> + </ul> + </t> + </li> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml new file mode 100644 index 00000000..4825efaf --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductItem.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductItem" owl="1"> + <article class="product" tabindex="0" t-on-keypress="spaceClickProduct" + t-on-click="trigger('click-product', props.product)" + t-att-data-product-id="props.product.id" + t-attf-aria-labelledby="article_product_{{props.product.id}}"> + <div class="product-img"> + <img t-att-src="imageUrl" t-att-alt="props.product.display_name" /> + <span class="price-tag"> + <t t-esc="price" /> + </span> + </div> + <div class="product-name" t-attf-id="article_product_{{props.product.id}}"> + <t t-esc="props.product.display_name" /> + </div> + </article> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml new file mode 100644 index 00000000..9e87ca95 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductList.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductList" owl="1"> + <div class="product-list-container"> + <div t-if="props.products.length != 0" class="product-list"> + <t t-foreach="props.products" t-as="product" t-key="product.id"> + <ProductItem product="product" /> + </t> + </div> + <div t-else="" class="product-list-empty"> + <div class="product-list-empty"> + <t t-if="props.searchWord !== ''"> + <p> + No results found for " + <b t-esc="props.searchWord"></b> + ". + </p> + </t> + <t t-else=""> + <p>There are no products in this category.</p> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml new file mode 100644 index 00000000..6bef9281 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductScreen.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductScreen" owl="1"> + <div class="product-screen screen" t-att-class="{ oe_hidden: !props.isShown }"> + <div class="screen-full-width"> + <div class="leftpane" t-if="!env.isMobile || mobile_pane === 'left'"> + <t t-if="state.cashControl"> + <CashBoxOpening cashControl="state"/> + </t> + <t t-else=""> + <OrderWidget/> + <div class="pads"> + <div class="control-buttons"> + <t t-foreach="controlButtons" t-as="cb" t-key="cb.name"> + <t t-component="cb.component" t-key="cb.name" /> + </t> + </div> + <div class="subpads"> + <ActionpadWidget client="client"/> + <NumpadWidget activeMode="state.numpadMode" /> + </div> + </div> + <t t-if="env.isMobile"> + <MobileOrderWidget pane="mobile_pane" t-on-switchpane="switchPane"/> + </t> + </t> + </div> + <div class="rightpane" t-if="!env.isMobile || mobile_pane === 'right'"> + <ProductsWidget t-if="!state.cashControl"/> + <t t-if="env.isMobile"> + <MobileOrderWidget pane="mobile_pane" t-on-switchpane="switchPane"/> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml new file mode 100644 index 00000000..3dfd5276 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidget.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductsWidget" owl="1"> + <div class="products-widget"> + <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" hasNoCategories="hasNoCategories" /> + <ProductList products="productsToDisplay" searchWord="searchWord" /> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml new file mode 100644 index 00000000..2c7d7727 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ProductsWidgetControlPanel" owl="1"> + <div class="products-widget-control"> + <t t-if="!props.hasNoCategories"> + <div class="rightpane-header" t-att-class="{ + 'green-border-bottom': !env.pos.config.iface_display_categ_images, + 'grey-border-bottom': env.pos.config.iface_display_categ_images, + }"> + <!-- Breadcrumbs --> + <div class="breadcrumbs"> + <HomeCategoryBreadcrumb subcategories="props.subcategories" currentCat="props.breadcrumbs[props.breadcrumbs.length - 1]"/> + <t t-if="!env.isMobile"> + <t t-foreach="props.breadcrumbs" t-as="category" t-key="category.id"> + <CategoryBreadcrumb category="category" /> + </t> + </t> + </div> + <!-- Subcategories --> + <t t-if="props.subcategories.length > 0 and !env.pos.config.iface_display_categ_images and !env.isMobile"> + <t t-foreach="props.subcategories" t-as="category" t-key="category.id"> + <CategorySimpleButton category="category" /> + </t> + </t> + </div> + <t t-if="props.subcategories.length > 0 and env.pos.config.iface_display_categ_images and !env.isMobile"> + <div class="categories"> + <div class="category-list-scroller"> + <div class="category-list"> + <t t-foreach="props.subcategories" t-as="category" t-key="category.id"> + <CategoryButton category="category" /> + </t> + </div> + </div> + </div> + </t> + </t> + <Portal target="'.pos .search-bar-portal'"> + <div class="search-box"> + <span class="icon"><i class="fa fa-search"></i></span> + <span t-on-click="clearSearch" class="clear-icon"> + <i class="fa fa-times" aria-hidden="true"></i> + </span> + <input t-ref="search-word-input" type="text" placeholder="Search Products..." t-on-keyup="updateSearch" /> + </div> + </Portal> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml new file mode 100644 index 00000000..379b360d --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml @@ -0,0 +1,211 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="OrderReceipt" owl="1"> + <div class="pos-receipt"> + <t t-if="receipt.company.logo"> + <img class="pos-receipt-logo" t-att-src="receipt.company.logo" alt="Logo"/> + <br/> + </t> + <t t-if="!receipt.company.logo"> + <h2 class="pos-receipt-center-align"> + <t t-esc="receipt.company.name" /> + </h2> + <br/> + </t> + <div class="pos-receipt-contact"> + <t t-if="receipt.company.contact_address"> + <div><t t-esc="receipt.company.contact_address" /></div> + </t> + <t t-if="receipt.company.phone"> + <div>Tel:<t t-esc="receipt.company.phone" /></div> + </t> + <t t-if="receipt.company.vat"> + <div><t t-esc="receipt.company.vat_label"/>:<t t-esc="receipt.company.vat" /></div> + </t> + <t t-if="receipt.company.email"> + <div><t t-esc="receipt.company.email" /></div> + </t> + <t t-if="receipt.company.website"> + <div><t t-esc="receipt.company.website" /></div> + </t> + <t t-if="receipt.header_html"> + <t t-raw="receipt.header_html" /> + </t> + <t t-if="!receipt.header_html and receipt.header"> + <div style="white-space:pre-line"><t t-esc="receipt.header" /></div> + </t> + <t t-if="receipt.cashier"> + <div class="cashier"> + <div>--------------------------------</div> + <div>Served by <t t-esc="receipt.cashier" /></div> + </div> + </t> + </div> + <br /><br /> + + <!-- Orderlines --> + + <div class="orderlines"> + <t t-foreach="receipt.orderlines" t-as="line" t-key="line.id"> + <t t-if="isSimple(line)"> + <div> + <t t-esc="line.product_name_wrapped[0]" /> + <span t-esc="env.pos.format_currency_no_symbol(line.price_display)" class="price_display pos-receipt-right-align"/> + </div> + <WrappedProductNameLines line="line" /> + </t> + <t t-else=""> + <div t-esc="line.product_name_wrapped[0]" /> + <WrappedProductNameLines line="line" /> + <t t-if="line.display_discount_policy == 'without_discount' and line.price != line.price_lst"> + <div class="pos-receipt-left-padding"> + <t t-esc="env.pos.format_currency_no_symbol(line.price_lst)" /> + -> + <t t-esc="env.pos.format_currency_no_symbol(line.price)" /> + </div> + </t> + <t t-elif="line.discount !== 0"> + <div class="pos-receipt-left-padding"> + <t t-if="env.pos.config.iface_tax_included === 'total'"> + <t t-esc="env.pos.format_currency_no_symbol(line.price_with_tax_before_discount)"/> + </t> + <t t-else=""> + <t t-esc="env.pos.format_currency_no_symbol(line.price)"/> + </t> + </div> + </t> + <t t-if="line.discount !== 0"> + <div class="pos-receipt-left-padding"> + Discount: <t t-esc="line.discount" />% + </div> + </t> + <div class="pos-receipt-left-padding"> + <t t-esc="Math.round(line.quantity * Math.pow(10, env.pos.dp['Product Unit of Measure'])) / Math.pow(10, env.pos.dp['Product Unit of Measure'])"/> + <t t-if="!line.is_in_unit" t-esc="line.unit_name" /> + x + <t t-esc="env.pos.format_currency_no_symbol(line.price_display_one)" /> + <span class="price_display pos-receipt-right-align"> + <t t-esc="env.pos.format_currency_no_symbol(line.price_display)" /> + </span> + </div> + </t> + <t t-if="line.pack_lot_lines"> + <div class="pos-receipt-left-padding"> + <ul> + <t t-foreach="line.pack_lot_lines" t-as="lot" t-key="lot.cid"> + <li> + SN <t t-esc="lot.attributes['lot_name']"/> + </li> + </t> + </ul> + </div> + </t> + </t> + </div> + + <!-- Subtotal --> + + <t t-if="!isTaxIncluded"> + <div class="pos-receipt-right-align">--------</div> + <br/> + <div>Subtotal<span t-esc="env.pos.format_currency(receipt.subtotal)" class="pos-receipt-right-align"/></div> + <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name"> + <div> + <t t-esc="tax.name" /> + <span t-esc='env.pos.format_currency_no_symbol(tax.amount)' class="pos-receipt-right-align"/> + </div> + </t> + </t> + + <!-- Total --> + <div class="pos-receipt-right-align">--------</div> + <br/> + <div class="pos-receipt-amount"> + TOTAL + <span t-esc="env.pos.format_currency(receipt.total_with_tax)" class="pos-receipt-right-align"/> + </div> + <t t-if="receipt.total_rounded != receipt.total_with_tax"> + <div class="pos-receipt-amount"> + Rounding + <span t-esc='env.pos.format_currency(receipt.rounding_applied)' class="pos-receipt-right-align"/> + </div> + <div class="pos-receipt-amount"> + To Pay + <span t-esc='env.pos.format_currency(receipt.total_rounded)' class="pos-receipt-right-align"/> + </div> + </t> + <br/><br/> + + <!-- Payment Lines --> + + <t t-foreach="receipt.paymentlines" t-as="line" t-key="line.cid"> + <div> + <t t-esc="line.name" /> + <span t-esc="env.pos.format_currency_no_symbol(line.amount)" class="pos-receipt-right-align"/> + </div> + </t> + <br/> + + <div class="pos-receipt-amount receipt-change"> + CHANGE + <span t-esc="env.pos.format_currency(receipt.change)" class="pos-receipt-right-align"/> + </div> + <br/> + + <!-- Extra Payment Info --> + + <t t-if="receipt.total_discount"> + <div> + Discounts + <span t-esc="env.pos.format_currency(receipt.total_discount)" class="pos-receipt-right-align"/> + </div> + </t> + <t t-if="isTaxIncluded"> + <t t-foreach="receipt.tax_details" t-as="tax" t-key="tax.name"> + <div> + <t t-esc="tax.name" /> + <span t-esc="env.pos.format_currency_no_symbol(tax.amount)" class="pos-receipt-right-align"/> + </div> + </t> + <div> + Total Taxes + <span t-esc="env.pos.format_currency(receipt.total_tax)" class="pos-receipt-right-align"/> + </div> + </t> + + <div class="before-footer" /> + + <!-- Footer --> + <div t-if="receipt.footer_html" class="pos-receipt-center-align"> + <t t-raw="receipt.footer_html" /> + </div> + + <div t-if="!receipt.footer_html and receipt.footer" class="pos-receipt-center-align" style="white-space:pre-line"> + <br/> + <t t-esc="receipt.footer" /> + <br/> + <br/> + </div> + + <div class="after-footer"> + <t t-foreach="receipt.paymentlines" t-as="line"> + <t t-if="line.ticket"> + <br /> + <div class="pos-payment-terminal-receipt"> + <t t-raw="line.ticket" /> + </div> + </t> + </t> + </div> + + <br/> + <div class="pos-receipt-order-data"> + <div><t t-esc="receipt.name" /></div> + <div><t t-esc="receipt.date.localestring" /></div> + </div> + + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml new file mode 100644 index 00000000..8f0bd54a --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="ReceiptScreen" owl="1"> + <div class="receipt-screen screen"> + <div class="screen-content"> + <div class="top-content"> + <div class="top-content-center"> + <h1 t-if="!env.isMobile"> + <t t-esc="orderAmountPlusTip" /> + </h1> + </div> + <div class="button next" t-att-class="{ highlight: !locked }" + t-on-click="orderDone"> + New Order <i class="fa fa-angle-double-right"></i> + </div> + </div> + <div class="default-view"> + <div class="pos-receipt-container"> + <OrderReceipt order="currentOrder" t-ref="order-receipt" /> + </div> + <div class="actions"> + <h1>How would you like to receive your receipt?</h1> + <div class="buttons"> + <div class="button print" t-on-click="printReceipt"> + <i class="fa fa-print"></i> Print Receipt + </div> + </div> + <form t-on-submit.prevent="onSendEmail" class="send-email"> + <div class="email-icon"><i class="fa fa-envelope-o" aria-hidden="true"></i></div> + <div class="input-email"> + <input type="email" placeholder="Email Receipt" t-model="orderUiState.inputEmail" /> + <button class="send" t-att-class="{ highlight: is_email(orderUiState.inputEmail) }" type="submit">Send</button> + </div> + </form> + <t t-if="orderUiState.emailSuccessful !== null"> + <div class="notice" t-attf-class="{{ orderUiState.emailSuccessful ? 'successful' : 'failed' }}"> + <t t-esc="orderUiState.emailNotice"></t> + </div> + </t> + </div> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml new file mode 100644 index 00000000..d49061a8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/WrappedProductNameLines.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="WrappedProductNameLines" owl="1"> + <span> + <t t-foreach="props.line.product_name_wrapped.slice(1)" t-as="wrapped_line"><t t-esc="wrapped_line"/></t> + </span> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml new file mode 100644 index 00000000..de21dcc3 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ScaleScreen/ScaleScreen.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates id="template" xml:space="preserve"> + + <t t-name="ScaleScreen" owl="1"> + <div class="scale-screen screen"> + <div class="screen-content"> + <div class="top-content"> + <span class="button back" t-on-click="back"> + <i class="fa fa-angle-double-left"></i> + Back + </span> + <h1 class="product-name"> + <t t-esc="productName" /> + </h1> + </div> + <div class="centered-content"> + <div class="weight js-weight"> + <t t-esc="productWeightString" /> + </div> + <div class="product-price"> + <t + t-esc="env.pos.format_currency(productPrice) + '/' + productUom" /> + </div> + <div class="computed-price"> + <t t-esc="computedPriceString" /> + </div> + <div class="buy-product" t-on-click="confirm"> + Order + <i class="fa fa-angle-double-right"></i> + </div> + </div> + </div> + </div> + </t> + +</templates>
\ No newline at end of file diff --git a/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml new file mode 100644 index 00000000..40bfc501 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/TicketScreen/TicketScreen.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + + <t t-name="TicketScreen" owl="1"> + <div class="ticket-screen screen"> + <div class="screen-content"> + <div class="controls"> + <div class="buttons"> + <button t-if="showNewTicketButton" class="highlight" t-on-click="createNewOrder">New Order</button> + <button class="discard" t-on-click="trigger('close-screen')">Discard</button> + </div> + <t t-set="placeholder">Search Tickets...</t> + <SearchBar config="searchBarConfig" placeholder="placeholder" /> + </div> + <div class="orders"> + <div class="header-row"> + <div class="col start wide">Date</div> + <div class="col start wide">Receipt Number</div> + <div class="col start">Customer</div> + <div class="col start wide" t-if="showCardholderName()">Cardholder Name</div> + <div class="col start">Employee</div> + <div class="col end">Total</div> + <div class="col start narrow">Status</div> + <div class="col center very-narrow" name="delete"></div> + </div> + <t t-foreach="filteredOrderList" t-as="order" t-key="order.cid"> + <div class="order-row pointer" t-on-click="selectOrder(order)"> + <div class="col start wide"> + <t t-esc="getDate(order)"></t> + </div> + <div class="col start wide"> + <t t-esc="order.name"></t> + </div> + <div class="col start"> + <t t-esc="getCustomer(order)"></t> + </div> + <div t-if="showCardholderName()" class="col start"> + <t t-esc="getCardholderName(order)"></t> + </div> + <div class="col start"> + <t t-esc="getEmployee(order)"></t> + </div> + <div class="col end"> + <t t-esc="getTotal(order)"></t> + </div> + <div class="col start narrow"> + <t t-esc="getStatus(order)"></t> + </div> + <div t-if="!hideDeleteButton(order)" class="col center very-narrow delete-button" name="delete" t-on-click.stop="deleteOrder(order)"> + <i class="fa fa-trash" aria-hidden="true"></i> + </div> + <div t-else="" class="col center very-narrow delete-button"></div> + </div> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/point_of_sale/static/src/xml/debug_manager.xml b/addons/point_of_sale/static/src/xml/debug_manager.xml new file mode 100644 index 00000000..7ecc7cc1 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/debug_manager.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + +<t t-extend="WebClient.DebugManager.Backend"> + <t t-jquery="a[data-action='perform_click_everywhere_test']" t-operation="after"> + <a role="menuitem" href="#" data-action="perform_pos_js_tests" class="dropdown-item">Run Point of Sale JS Tests</a> + </t> +</t> + +</templates> |
