From 89115688f7f357a35a9c73088a6dff42c0874f78 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Fri, 23 May 2025 12:36:21 +0530 Subject: [PATCH] fix: pos screen ui ux (#47680) * fix: pos addl info dialog submit form on save * feat: new invoice and recent order button on page action * fix: item cart highlighted item scrolling * fix: using icon instead of text in fullscreen button * fix: search field clear button alignment * fix: hide item selector on item details display * fix: using add_action_icon * fix: action of 'New Invoice' for unsaved changes * fix: highlight numpad btns on hover * fix: pos recent orders filter and list items * chore: added icons for pos icon buttons * fix: recent order toggle after invoice submission * fix: capitalized text in select options --- .../doctype/pos_profile/pos_profile.json | 10 +- .../doctype/pos_profile/pos_profile.py | 3 + erpnext/hooks.py | 8 + erpnext/public/icons/pos-icons.svg | 15 ++ erpnext/public/scss/point-of-sale.scss | 57 ++++--- .../page/point_of_sale/pos_controller.js | 143 ++++++++++-------- .../page/point_of_sale/pos_item_cart.js | 7 +- .../page/point_of_sale/pos_item_selector.js | 2 +- .../page/point_of_sale/pos_past_order_list.js | 22 ++- .../point_of_sale/pos_past_order_summary.js | 1 + .../selling/page/point_of_sale/pos_payment.js | 24 +-- 11 files changed, 184 insertions(+), 108 deletions(-) create mode 100644 erpnext/public/icons/pos-icons.svg diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 3714c500d13..4e37791e078 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -32,6 +32,7 @@ "allow_rate_change", "allow_discount_change", "set_grand_total_to_default_mop", + "action_on_new_invoice", "section_break_23", "item_groups", "column_break_25", @@ -415,6 +416,13 @@ "fieldname": "set_grand_total_to_default_mop", "fieldtype": "Check", "label": "Set Grand Total to Default Payment Method" + }, + { + "default": "Always Ask", + "fieldname": "action_on_new_invoice", + "fieldtype": "Select", + "label": "Action on New Invoice", + "options": "Always Ask\nSave Changes and Load New Invoice\nDiscard Changes and Load New Invoice" } ], "grid_page_length": 50, @@ -443,7 +451,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2025-05-09 11:23:28.632136", + "modified": "2025-05-23 12:12:32.247652", "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 1d482ddb8e1..e3e5c84d3d9 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -28,6 +28,9 @@ class POSProfile(Document): from erpnext.accounts.doctype.pos_profile_user.pos_profile_user import POSProfileUser account_for_change_amount: DF.Link | None + action_on_new_invoice: DF.Literal[ + "Always Ask", "Save Changes and Load New Invoice", "Discard Changes and Load New Invoice" + ] allow_discount_change: DF.Check allow_rate_change: DF.Check applicable_for_users: DF.Table[POSProfileUser] diff --git a/erpnext/hooks.py b/erpnext/hooks.py index b8d9f97d625..316abe9fe27 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -28,6 +28,14 @@ web_include_js = "erpnext-web.bundle.js" web_include_css = "erpnext-web.bundle.css" email_css = "email_erpnext.bundle.css" +app_include_icons = [ + "/assets/erpnext/icons/pos-icons.svg", +] + +web_include_icons = [ + "/assets/erpnext/icons/pos-icons.svg", +] + doctype_js = { "Address": "public/js/address.js", "Communication": "public/js/communication.js", diff --git a/erpnext/public/icons/pos-icons.svg b/erpnext/public/icons/pos-icons.svg new file mode 100644 index 00000000000..37230d82498 --- /dev/null +++ b/erpnext/public/icons/pos-icons.svg @@ -0,0 +1,15 @@ + + + diff --git a/erpnext/public/scss/point-of-sale.scss b/erpnext/public/scss/point-of-sale.scss index 0308567d5d5..a5912cd8e62 100644 --- a/erpnext/public/scss/point-of-sale.scss +++ b/erpnext/public/scss/point-of-sale.scss @@ -579,7 +579,11 @@ align-items: center; justify-content: center; padding: var(--padding-md); - box-shadow: var(--shadow-sm); + box-shadow: var(--shadow-base); + + &:hover { + background-color: var(--control-bg); + } } > .col-span-2 { @@ -615,26 +619,30 @@ background-color: var(--control-bg); } - > .invoice-name-date { + &.invoice-selected { + background-color: var(--control-bg); + } + + > .invoice-name-customer { display: flex; flex-direction: column; justify-content: space-around; - > .invoice-name { + > .invoice-customer { @extend .nowrap; font-size: var(--text-md); + display: flex; + align-items: center; font-weight: 700; } - > .invoice-date { + > .invoice-name { @extend .nowrap; font-size: var(--text-sm); - display: flex; - align-items: center; } } - > .invoice-total-status { + > .invoice-total-date { display: flex; flex-direction: column; font-weight: 500; @@ -648,17 +656,19 @@ text-align: right; } - > .invoice-status { + > .invoice-date { display: flex; align-items: center; + color: var(--gray-500); justify-content: right; + font-weight: 400; } } } > .item-details-container { @extend .pos-card; - grid-column: span 4 / span 4; + grid-column: span 6 / span 6; display: none; flex-direction: column; padding: var(--padding-lg); @@ -915,6 +925,10 @@ justify-content: center; padding: var(--padding-md); box-shadow: var(--shadow-base); + + &:hover { + background-color: var(--control-bg); + } } } } @@ -978,18 +992,23 @@ background-color: var(--fg-color); padding: var(--padding-lg); - > .search-field { - width: 100%; - display: flex; - align-items: center; + > .status-search-fields { + display: grid; + grid-template-columns: 30% auto; + column-gap: 10px; margin-top: var(--margin-md); - margin-bottom: var(--margin-xs); - } - > .status-field { - width: 100%; - display: flex; - align-items: center; + > .status-field { + width: 100%; + display: flex; + align-items: center; + } + + > .search-field { + width: 100%; + display: flex; + align-items: center; + } } } diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 9cd61de95af..bc9add3dfba 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -213,7 +213,7 @@ erpnext.PointOfSale.Controller = class { this.prepare_dom(); this.prepare_components(); this.prepare_menu(); - this.prepare_fullscreen_btn(); + this.prepare_btns(); this.make_new_invoice(); } @@ -234,52 +234,42 @@ erpnext.PointOfSale.Controller = class { prepare_menu() { this.page.clear_menu(); - this.page.add_menu_item(__("Open Form View"), this.open_form_view.bind(this), false, "Ctrl+F"); - - this.page.add_menu_item( - __("Toggle Recent Orders"), - this.toggle_recent_order.bind(this), - false, - "Ctrl+O" - ); - - this.page.add_menu_item(__("Save as Draft"), this.save_draft_invoice.bind(this), false, "Ctrl+S"); - this.page.add_menu_item(__("Close the POS"), this.close_pos.bind(this), false, "Shift+Ctrl+C"); } - prepare_fullscreen_btn() { - this.page.page_actions.find(".custom-actions").empty(); - - this.page.add_button(__("Full Screen"), null, { btn_class: "btn-default fullscreen-btn" }); - - this.bind_fullscreen_events(); + prepare_btns() { + this.page.clear_custom_actions(); + this.page.clear_icons(); + this.page.set_primary_action(__("New Invoice"), this.new_invoice_event.bind(this)); + this.page.set_secondary_action(__("Recent Orders"), this.toggle_recent_order.bind(this)); + this.page.add_action_icon( + "fullscreen", + this.bind_fullscreen_events.bind(this), + "btn-fullscreen", + "Fullscreen" + ); + this.page.add_action_icon( + "minimize", + this.bind_fullscreen_events.bind(this), + "btn-minimize hide", + "Minimize" + ); } bind_fullscreen_events() { - this.$fullscreen_btn = this.page.page_actions.find(".fullscreen-btn"); - - this.$fullscreen_btn.on("click", function () { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen(); - } else if (document.exitFullscreen) { - document.exitFullscreen(); - } - }); - - $(document).on("fullscreenchange", this.handle_fullscreen_change_event.bind(this)); + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + this.toggle_fullscreen_btn(".btn-minimize", ".btn-fullscreen"); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + this.toggle_fullscreen_btn(".btn-fullscreen", ".btn-minimize"); + } } - handle_fullscreen_change_event() { - let enable_fullscreen_label = __("Full Screen"); - let exit_fullscreen_label = __("Exit Full Screen"); - - if (document.fullscreenElement) { - this.$fullscreen_btn[0].innerText = exit_fullscreen_label; - } else { - this.$fullscreen_btn[0].innerText = enable_fullscreen_label; - } + toggle_fullscreen_btn(show, hide) { + this.page.page_actions.find(hide).addClass("hide"); + this.page.page_actions.find(show).removeClass("hide"); } open_form_view() { @@ -289,36 +279,48 @@ erpnext.PointOfSale.Controller = class { toggle_recent_order() { const show = this.recent_order_list.$component.is(":hidden"); + this.page.btn_secondary.get(0).innerText = show ? __("Hide Recent Orders") : __("Recent Orders"); this.toggle_recent_order_list(show); } - save_draft_invoice() { + new_invoice_event() { + const me = this; if (!this.$components_wrapper.is(":visible")) return; - if (this.frm.doc.items.length == 0) { - frappe.show_alert({ - message: __("You must add atleast one item to save it as draft."), - indicator: "red", - }); - frappe.utils.play_sound("error"); + if (this.frm.doc.items.length !== 0 && (this.frm.is_new() || this.frm.is_dirty())) { + if (this.settings.action_on_new_invoice === "Always Ask") { + frappe.confirm( + __("You have unsaved changes. Do you want to save the invoice?"), + () => { + me.frm.save().then(me.load_new_invoice_on_pos.bind(me)); + }, + () => { + me.load_new_invoice_on_pos(); + } + ); + return; + } else if (this.settings.action_on_new_invoice === "Save Changes and Load New Invoice") { + this.frm.save().then(me.load_new_invoice_on_pos.bind(me)); + return; + } + + this.load_new_invoice_on_pos(); return; } - this.frm - .save(undefined, undefined, undefined, () => { - frappe.show_alert({ - message: __("There was an error saving the document."), - indicator: "red", - }); - frappe.utils.play_sound("error"); - }) - .then(() => { - frappe.run_serially([ - () => frappe.dom.freeze(), - () => this.make_new_invoice(), - () => frappe.dom.unfreeze(), - ]); - }); + if (this.payment.$component.is(":visible")) { + this.load_new_invoice_on_pos(); + } + } + + load_new_invoice_on_pos() { + frappe.run_serially([ + () => frappe.dom.freeze(), + () => this.make_new_invoice(), + () => this.toggle_recent_order_list(false), + () => this.toggle_components(true), + () => frappe.dom.unfreeze(), + ]); } close_pos() { @@ -384,7 +386,7 @@ erpnext.PointOfSale.Controller = class { get_frm: () => this.frm, toggle_item_selector: (minimize) => { - this.item_selector.resize_selector(minimize); + this.item_selector.toggle_component(!minimize); this.cart.toggle_numpad(minimize); }, @@ -468,8 +470,7 @@ erpnext.PointOfSale.Controller = class { submit_invoice: () => { this.frm.savesubmit().then((r) => { this.toggle_components(false); - this.order_summary.toggle_component(true); - this.order_summary.load_summary_of(this.frm.doc, true); + this.toggle_submitted_invoice_summary(true); frappe.show_alert({ indicator: "green", message: __("POS invoice {0} created successfully", [r.doc.name]), @@ -518,7 +519,7 @@ erpnext.PointOfSale.Controller = class { }); }, edit_order: (doctype, name) => { - this.recent_order_list.toggle_component(false); + this.toggle_recent_order(); frappe.run_serially([ () => this.make_invoice_frm(doctype), () => this.sync_draft_invoice_to_frm(doctype, name), @@ -550,19 +551,29 @@ erpnext.PointOfSale.Controller = class { } toggle_recent_order_list(show) { - this.toggle_components(!show); + this.frm.doc.docstatus === 1 + ? this.toggle_submitted_invoice_summary(!show) + : this.toggle_components(!show); + this.recent_order_list.toggle_component(show); - this.order_summary.toggle_component(show); + if (this.frm.doc.docstatus === 0) this.order_summary.toggle_component(show); } toggle_components(show) { this.cart.toggle_component(show); + this.cart.toggle_numpad(!show); + this.cart.toggle_checkout_btn(show); this.item_selector.toggle_component(show); // do not show item details or payment if recent order is toggled off !show ? this.item_details.toggle_component(false) || this.payment.toggle_component(false) : ""; } + toggle_submitted_invoice_summary(show) { + this.order_summary.toggle_component(show); + this.order_summary.load_summary_of(this.frm.doc, true); + } + make_new_invoice() { return frappe.run_serially([ () => frappe.dom.freeze(), 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 f6a4fcbf509..f4eeccf4e5b 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -171,8 +171,11 @@ 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 numpad_section_hidden = !me.$numpad_section.is(":visible"); + if (numpad_section_hidden) { + 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) { diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index b5d90228a2f..68ddd5434c9 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -188,7 +188,7 @@ erpnext.PointOfSale.ItemSelector = class { attach_clear_btn() { this.search_field.$wrapper.find(".control-input").append( - ` + ` ${frappe.utils.icon("close", "sm")} diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_list.js b/erpnext/selling/page/point_of_sale/pos_past_order_list.js index dfc6c2d46db..08c34c95786 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_list.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_list.js @@ -17,8 +17,10 @@ erpnext.PointOfSale.PastOrderList = class { `
${__("Recent Orders")}
-
-
+
+
+
+
` @@ -38,8 +40,12 @@ erpnext.PointOfSale.PastOrderList = class { }); const me = this; this.$invoices_container.on("click", ".invoice-wrapper", function () { - const invoice_doctype = $(this).attr("data-invoice-doctype"); - const invoice_name = unescape($(this).attr("data-invoice-name")); + const invoice_clicked = $(this); + const invoice_doctype = invoice_clicked.attr("data-invoice-doctype"); + const invoice_name = unescape(invoice_clicked.attr("data-invoice-name")); + + $(".invoice-wrapper").removeClass("invoice-selected"); + invoice_clicked.addClass("invoice-selected"); me.events.open_invoice_data(invoice_doctype, invoice_name); }); @@ -103,16 +109,16 @@ erpnext.PointOfSale.PastOrderList = class { return `
-
-
${invoice.name}
-
+
+
${frappe.ellipsis(invoice.customer, 20)}
+
${invoice.name}
-
+
${format_currency(invoice.grand_total, invoice.currency) || 0}
${posting_datetime}
diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js index 18d9e6aad34..c26b53e5144 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js @@ -459,6 +459,7 @@ erpnext.PointOfSale.PastOrderSummary = class { } toggle_component(show) { + this.$component.css("grid-column", "span 6 / span 6"); show ? this.$component.css("display", "flex") : this.$component.css("display", "none"); } diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index d7855803fad..b4851586557 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -61,9 +61,15 @@ erpnext.PointOfSale.Payment = class { primary_action_label: __("Save"), primary_action(values) { me.set_values_to_frm(values); + if (this.complete_order) { + me.events.submit_invoice(); + } this.hide(); }, }); + me.addl_dlg.$wrapper.on("hide.bs.modal", function () { + me.addl_dlg.complete_order = false; + }); me.add_btn_field_click_listener(); me.set_value_on_dialog_fields(); me.make_addl_info_dialog_btn_visible(); @@ -218,10 +224,6 @@ erpnext.PointOfSale.Payment = class { const paid_amount = doc.paid_amount; const items = doc.items; - if (!this.validate_reqd_invoice_fields()) { - return; - } - if (!items.length || (paid_amount == 0 && doc.additional_discount_percentage != 100)) { const message = items.length ? __("You cannot submit the order without payment.") @@ -231,6 +233,10 @@ erpnext.PointOfSale.Payment = class { return; } + if (!this.validate_reqd_invoice_fields()) { + return; + } + this.events.submit_invoice(); }); @@ -683,16 +689,12 @@ erpnext.PointOfSale.Payment = class { } validate_reqd_invoice_fields() { + if (this.invoice_fields.length === 0) return true; const doc = this.events.get_frm().doc; for (const df of this.addl_dlg.fields) { if (df.reqd && !doc[df.fieldname]) { - frappe.show_alert({ - message: __( - "Invoice cannot be submitted without filling the mandatory Additional Information fields." - ), - indicator: "red", - }); - frappe.utils.play_sound("error"); + this.addl_dlg.primary_action_label = "Submit"; + this.addl_dlg.complete_order = true; this.addl_dlg.show(); this.addl_dlg.fields_dict[df.fieldname].$input.focus(); return false;