From 62cce38a069b66bee3c475a40595bd217bd87eb5 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Mon, 12 May 2025 16:07:22 +0530 Subject: [PATCH] fix: pos for small screen and checkout page (#47092) * feat: Prevent need for scrolling in lower screens (Point of Sale) * fix: pos checkout page * refactor: renaming variable in pos profile conf * fix: change variable name according to pos conf * fix: removing redundant api call * refactor: adding function * refactor: moving pos settings invoice fields to modal * fix: label for change amount and remaining amount * fix: always display numpad * refactor: function rename * fix: better information * fix: auto scroll to highlighted cart item * chore: added patch --------- Co-authored-by: devdiogenes --- .../doctype/pos_profile/pos_profile.json | 16 +- .../doctype/pos_profile/pos_profile.py | 2 +- erpnext/patches.txt | 1 + .../v15_0/set_grand_total_to_default_mop.py | 9 + .../public/js/controllers/taxes_and_totals.js | 4 +- erpnext/public/scss/point-of-sale.scss | 209 ++++++++++-------- .../page/point_of_sale/pos_controller.js | 23 ++ .../page/point_of_sale/pos_item_cart.js | 3 + .../selling/page/point_of_sale/pos_payment.js | 162 ++++++++------ 9 files changed, 258 insertions(+), 171 deletions(-) create mode 100644 erpnext/patches/v15_0/set_grand_total_to_default_mop.py diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index f774391fe2b..3714c500d13 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -31,7 +31,7 @@ "ignore_pricing_rule", "allow_rate_change", "allow_discount_change", - "disable_grand_total_to_default_mop", + "set_grand_total_to_default_mop", "section_break_23", "item_groups", "column_break_25", @@ -402,12 +402,6 @@ "fieldtype": "Check", "label": "Print Receipt on Order Complete" }, - { - "default": "0", - "fieldname": "disable_grand_total_to_default_mop", - "fieldtype": "Check", - "label": "Disable auto setting Grand Total to default Payment Mode" - }, { "fieldname": "project", "fieldtype": "Link", @@ -415,6 +409,12 @@ "oldfieldname": "cost_center", "oldfieldtype": "Link", "options": "Project" + }, + { + "default": "1", + "fieldname": "set_grand_total_to_default_mop", + "fieldtype": "Check", + "label": "Set Grand Total to Default Payment Method" } ], "grid_page_length": 50, @@ -443,7 +443,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2025-04-09 11:35:13.779613", + "modified": "2025-05-09 11:23:28.632136", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 399d78d3997..1d482ddb8e1 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -40,7 +40,6 @@ class POSProfile(Document): currency: DF.Link customer: DF.Link | None customer_groups: DF.Table[POSCustomerGroup] - disable_grand_total_to_default_mop: DF.Check disable_rounded_total: DF.Check disabled: DF.Check expense_account: DF.Link | None @@ -56,6 +55,7 @@ class POSProfile(Document): project: DF.Link | None select_print_heading: DF.Link | None selling_price_list: DF.Link | None + set_grand_total_to_default_mop: DF.Check tax_category: DF.Link | None taxes_and_charges: DF.Link | None tc_name: DF.Link | None diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ec3ae09117b..1d9dc0607e0 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -411,3 +411,4 @@ erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices erpnext.patches.v15_0.rename_group_by_to_categorize_by execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") erpnext.patches.v14_0.set_update_price_list_based_on +erpnext.patches.v15_0.set_grand_total_to_default_mop diff --git a/erpnext/patches/v15_0/set_grand_total_to_default_mop.py b/erpnext/patches/v15_0/set_grand_total_to_default_mop.py new file mode 100644 index 00000000000..742fee0b775 --- /dev/null +++ b/erpnext/patches/v15_0/set_grand_total_to_default_mop.py @@ -0,0 +1,9 @@ +import frappe + + +def execute(): + POSProfile = frappe.qb.DocType("POS Profile") + + frappe.qb.update(POSProfile).set(POSProfile.set_grand_total_to_default_mop, 1).where( + POSProfile.disable_grand_total_to_default_mop == 0 + ).run() diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index bab34a3f665..a2b9ec1e7dc 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -947,9 +947,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var me = this; var payment_status = true; if(this.frm.doc.is_pos && (update_paid_amount===undefined || update_paid_amount)) { - let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "disable_grand_total_to_default_mop"); + let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "set_grand_total_to_default_mop"); - if (r.message.disable_grand_total_to_default_mop) { + if (!r.message.set_grand_total_to_default_mop) { return; } diff --git a/erpnext/public/scss/point-of-sale.scss b/erpnext/public/scss/point-of-sale.scss index b8acaeae9a9..0308567d5d5 100644 --- a/erpnext/public/scss/point-of-sale.scss +++ b/erpnext/public/scss/point-of-sale.scss @@ -5,9 +5,9 @@ padding: 1%; section { - min-height: 45rem; - height: calc(100vh - 200px); - max-height: calc(100vh - 200px); + min-height: 30rem; + height: calc(100vh - 125px); + max-height: calc(100vh - 125px); } .frappe-control { @@ -375,6 +375,7 @@ flex-direction: column; flex: 1 1 0%; overflow-y: scroll; + min-height: 50px; > .cart-item-wrapper { @extend .pointer-no-select; @@ -775,6 +776,7 @@ .submit-order-btn { @extend .primary-action; + margin-top: 0%; background-color: var(--btn-primary); color: var(--neutral); } @@ -785,117 +787,136 @@ margin-bottom: var(--margin-md); } - > .payment-modes { + > .payment-split-container { display: flex; - padding-bottom: var(--padding-sm); - margin-bottom: var(--margin-sm); - overflow-x: scroll; - overflow-y: hidden; - flex-shrink: 0; - > .payment-mode-wrapper { - min-width: 40%; - padding: var(--padding-xs); + > .payment-container-left { + width: 50%; + margin-bottom: var(--margin-md); - > .mode-of-payment { - @extend .pos-card; - @extend .pointer-no-select; - padding: var(--padding-md) var(--padding-lg); + .payment-modes { + display: flex; + flex-direction: column; + padding-right: var(--padding-sm); + margin-right: var(--margin-sm); + min-height: 15rem; + overflow-y: scroll; + height: calc(100vh - 350px); - > .pay-amount { - display: inline; - float: right; - font-weight: 700; - } + > .payment-mode-wrapper { + min-width: 40%; + padding: var(--padding-xs); - > .mode-of-payment-control { - display: none; - align-items: center; - margin-top: var(--margin-sm); - margin-bottom: var(--margin-xs); - } - - > .loyalty-amount-name { - display: none; - float: right; - font-weight: 700; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - > .cash-shortcuts { - display: none; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--margin-sm); - font-size: var(--text-sm); - text-align: center; - - > .shortcut { + > .mode-of-payment { + @extend .pos-card; @extend .pointer-no-select; - border-radius: var(--border-radius-sm); - background-color: var(--control-bg); - font-weight: 500; - padding: var(--padding-xs) var(--padding-sm); - transition: all 0.15s ease-in-out; + padding: var(--padding-md) var(--padding-lg); - &:hover { - background-color: var(--control-bg); + > .pay-amount { + display: inline; + float: right; + font-weight: 700; } + + > .mode-of-payment-control { + display: none; + align-items: center; + margin-top: var(--margin-sm); + margin-bottom: var(--margin-xs); + } + + > .loyalty-amount-name { + display: none; + float: right; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .cash-shortcuts { + display: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--margin-sm); + font-size: var(--text-sm); + text-align: center; + + > .shortcut { + @extend .pointer-no-select; + border-radius: var(--border-radius-sm); + background-color: var(--control-bg); + font-weight: 500; + padding: var(--padding-xs) var(--padding-sm); + transition: all 0.15s ease-in-out; + + &:hover { + background-color: var(--control-bg); + } + } + } + } + + > .loyalty-card { + display: flex; + flex-direction: column; } } } - - > .loyalty-card { - display: flex; - flex-direction: column; - } } - } - > .fields-numpad-container { - display: flex; - flex: 1; - height: 100%; - position: relative; - justify-content: flex-end; - - > .fields-section { - flex: 1; + > .payment-container-right { display: flex; flex-direction: column; width: 50%; - height: 100%; - padding-bottom: var(--margin-md); - .invoice-fields { - overflow-y: scroll; + .fields-numpad-container { + display: flex; + flex-direction: column; + flex: 1; height: 100%; - padding-right: var(--padding-sm); - } - } + position: relative; + justify-content: flex-end; - > .number-pad { - flex: 1; - display: flex; - justify-content: flex-end; - align-items: flex-end; - max-width: 50%; - - .numpad-container { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--margin-md); - margin-bottom: var(--margin-md); - - > .numpad-btn { - @extend .pointer-no-select; - border-radius: var(--border-radius-md); + > .fields-section { + flex: 1; display: flex; - align-items: center; - justify-content: center; - padding: var(--padding-md); - box-shadow: var(--shadow-sm); + flex-direction: column; + padding-left: var(--margin-md); + + .invoice-fields { + height: 100%; + margin-left: auto; + padding: var(--padding-sm); + } + } + + .number-pad { + position: absolute; + z-index: 4; + right: 0px; + flex: 1; + display: flex; + align-items: flex-end; + + .numpad-container { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--margin-md); + margin-bottom: var(--margin-md); + background-color: var(--fg-color); + border-radius: var(--border-radius-md); + padding: var(--padding-sm); + + > .numpad-btn { + @extend .pointer-no-select; + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + padding: var(--padding-md); + box-shadow: var(--shadow-base); + } + } } } } diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index c67ca67dac2..9cd61de95af 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -156,6 +156,28 @@ erpnext.PointOfSale.Controller = class { }, }); + this.fetch_invoice_fields(); + this.setup_listener_for_pos_closing(); + } + + fetch_invoice_fields() { + const me = this; + frappe.db.get_doc("POS Settings", undefined).then((doc) => { + me.settings.invoice_fields = doc.invoice_fields.map((field) => { + return { + fieldname: field.fieldname, + label: field.label, + fieldtype: field.fieldtype, + reqd: field.reqd, + options: field.options, + default_value: field.default_value, + read_only: field.read_only, + }; + }); + }); + } + + setup_listener_for_pos_closing() { frappe.realtime.on(`poe_${this.pos_opening}_closed`, (data) => { const route = frappe.get_route_str(); if (data && route == "point-of-sale") { @@ -426,6 +448,7 @@ erpnext.PointOfSale.Controller = class { init_payments() { this.payment = new erpnext.PointOfSale.Payment({ wrapper: this.$components_wrapper, + settings: this.settings, events: { get_frm: () => this.frm || {}, diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 3d70a63b579..f6a4fcbf509 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -171,6 +171,9 @@ erpnext.PointOfSale.ItemCart = class { me.toggle_item_highlight(this); + const scrollTop = $cart_item.offset().top - me.$cart_items_wrapper.offset().top; + me.$cart_items_wrapper.animate({ scrollTop }); + const payment_section_hidden = !me.$totals_section.find(".edit-cart-btn").is(":visible"); if (!payment_section_hidden) { // payment section is visible diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 56ca8b2154c..a0264753621 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -1,8 +1,10 @@ /* eslint-disable no-unused-vars */ erpnext.PointOfSale.Payment = class { - constructor({ events, wrapper }) { + constructor({ events, wrapper, settings }) { this.wrapper = wrapper; this.events = events; + this.set_gt_to_default_mop = settings.set_grand_total_to_default_mop; + this.invoice_fields = settings.invoice_fields; this.init_component(); } @@ -17,14 +19,23 @@ erpnext.PointOfSale.Payment = class { prepare_dom() { this.wrapper.append( `
- -
-
-
- -
+
+
+ +
+
+
+
+
+
+ +
+
+
+
-
@@ -40,48 +51,61 @@ erpnext.PointOfSale.Payment = class { this.$invoice_fields_section = this.$component.find(".fields-section"); } - make_invoice_fields_control() { - this.reqd_invoice_fields = []; - frappe.db.get_doc("POS Settings", undefined).then((doc) => { - const fields = doc.invoice_fields; - if (!fields.length) return; + make_invoice_field_dialog() { + const me = this; + if (!me.invoice_fields.length) return; + me.addl_dlg = new frappe.ui.Dialog({ + title: __("Additional Information"), + fields: me.invoice_fields, + size: "small", + primary_action_label: __("Save"), + primary_action(values) { + me.set_values_to_frm(values); + this.hide(); + }, + }); + me.add_btn_field_click_listener(); + me.set_value_on_dialog_fields(); + me.make_addl_info_dialog_btn_visible(); + } - this.$invoice_fields = this.$invoice_fields_section.find(".invoice-fields"); - this.$invoice_fields.html(""); - const frm = this.events.get_frm(); + set_values_to_frm(values) { + const frm = this.events.get_frm(); + for (const value in values) { + frm.set_value(value, values[value]); + } + frappe.show_alert({ + message: __("Additional Information updated successfully."), + indicator: "green", + }); + } - fields.forEach((df) => { - this.$invoice_fields.append( - `
` - ); - let df_events = { - onchange: function () { - frm.set_value(this.df.fieldname, this.get_value()); - }, - }; - if (df.fieldtype == "Button") { - df_events = { - click: function () { - if (frm.script_manager.has_handlers(df.fieldname, frm.doc.doctype)) { - frm.script_manager.trigger(df.fieldname, frm.doc.doctype, frm.doc.docname); - } - }, - }; - } - if (df.reqd && (df.fieldtype !== "Button" || !df.read_only)) { - this.reqd_invoice_fields.push({ fieldname: df.fieldname, label: df.label }); - } - - this[`${df.fieldname}_field`] = frappe.ui.form.make_control({ - df: { - ...df, - ...df_events, - }, - parent: this.$invoice_fields.find(`.${df.fieldname}-field`), - render_input: true, + add_btn_field_click_listener() { + const frm = this.events.get_frm(); + this.addl_dlg.fields.forEach((df) => { + if (df.fieldtype === "Button") { + this.addl_dlg.fields_dict[df.fieldname].$input.on("click", function () { + if (frm.script_manager.has_handlers(df.fieldname, frm.doc.doctype)) { + frm.script_manager.trigger(df.fieldname, frm.doc.doctype, frm.doc.docname); + } }); - this[`${df.fieldname}_field`].set_value(frm.doc[df.fieldname]); - }); + } + }); + } + + set_value_on_dialog_fields() { + const doc = this.events.get_frm().doc; + this.addl_dlg.fields.forEach((df) => { + if (doc[df.fieldname] || df.default_value) { + this.addl_dlg.set_value(df.fieldname, doc[df.fieldname] || df.default_value); + } + }); + } + + make_addl_info_dialog_btn_visible() { + this.$invoice_fields_section.find(".addl-fields").removeClass("hidden"); + this.$invoice_fields_section.find(".addl-fields").on("click", () => { + this.addl_dlg.show(); }); } @@ -164,6 +188,16 @@ erpnext.PointOfSale.Payment = class { } }); + frappe.ui.form.on("POS Invoice", "contact_mobile", (frm) => { + const contact = frm.doc.contact_mobile; + const request_button = $(this.request_for_payment_field?.$input[0]); + if (contact) { + request_button.removeClass("btn-default").addClass("btn-primary"); + } else { + request_button.removeClass("btn-primary").addClass("btn-default"); + } + }); + frappe.ui.form.on("POS Invoice", "coupon_code", (frm) => { this.bind_coupon_code_event(frm); }); @@ -355,9 +389,9 @@ erpnext.PointOfSale.Payment = class { render_payment_section() { this.render_payment_mode_dom(); - this.make_invoice_fields_control(); + this.make_invoice_field_dialog(); this.update_totals_section(); - this.unset_grand_total_to_default_mop(); + this.set_grand_total_to_default_mop(); } after_render() { @@ -610,7 +644,7 @@ erpnext.PointOfSale.Payment = class { const remaining = grand_total - doc.paid_amount; const change = doc.change_amount || remaining <= 0 ? -1 * remaining : undefined; const currency = doc.currency; - const label = __("Change Amount"); + const label = doc.paid_amount > grand_total ? __("Change Amount") : __("Remaining Amount"); this.$totals.html( `
@@ -642,32 +676,28 @@ erpnext.PointOfSale.Payment = class { .toLowerCase(); } - async unset_grand_total_to_default_mop() { - const doc = this.events.get_frm().doc; - let r = await frappe.db.get_value( - "POS Profile", - doc.pos_profile, - "disable_grand_total_to_default_mop" - ); - - if (!r.message.disable_grand_total_to_default_mop) { + set_grand_total_to_default_mop() { + if (this.set_gt_to_default_mop) { this.focus_on_default_mop(); } } validate_reqd_invoice_fields() { const doc = this.events.get_frm().doc; - let validation_flag = true; - for (let field of this.reqd_invoice_fields) { - if (!doc[field.fieldname]) { - validation_flag = false; + for (const df of this.addl_dlg.fields) { + if (df.reqd && !doc[df.fieldname]) { frappe.show_alert({ - message: __("{0} is a mandatory field.", [field.label]), - indicator: "orange", + message: __( + "Invoice cannot be submitted without filling the mandatory Additional Information fields." + ), + indicator: "red", }); frappe.utils.play_sound("error"); + this.addl_dlg.show(); + this.addl_dlg.fields_dict[df.fieldname].$input.focus(); + return false; } } - return validation_flag; + return true; } };