diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 1806aaa9c0a..bbfe563ed5e 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -38,6 +38,7 @@ web_include_icons = [ doctype_js = { "Address": "public/js/address.js", + "Sales Order": "public/js/sales_order_proforma.js", "Communication": "public/js/communication.js", "Event": "public/js/event.js", "Newsletter": "public/js/newsletter.js", diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js new file mode 100644 index 00000000000..1a9698a82c7 --- /dev/null +++ b/erpnext/public/js/sales_order_proforma.js @@ -0,0 +1,248 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +frappe.ui.form.on("Sales Order", { + refresh(frm) { + erpnext.proforma.toggle_tab(frm, false); + if (frm.doc.docstatus !== 1) return; + + frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => { + if (!enabled) return; + + frm.add_custom_button( + __("Proforma Invoice"), + () => erpnext.proforma.open_dialog(frm), + __("Create") + ); + erpnext.proforma.render_list(frm); + }); + }, +}); + +frappe.provide("erpnext.proforma"); + +Object.assign(erpnext.proforma, { + toggle_tab(frm, show) { + // Toggle the Tab Break itself: set_df_property refreshes the field control but not the + // tab link, so drive the Tab object directly to actually show/hide the tab. + const tab = frm.get_field("proforma_html")?.tab; + if (tab) { + tab.df.hidden = show ? 0 : 1; + tab.toggle(show); + } else { + frm.set_df_property("proforma_tab", "hidden", show ? 0 : 1); + } + }, + + open_dialog(frm) { + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.get_pending_proforma_qty", + args: { sales_order: frm.doc.name }, + callback: (r) => this.show_dialog(frm, r.message || []), + }); + }, + + show_dialog(frm, pending) { + frappe.model.with_doctype("Proforma Invoice", () => { + const series = frappe.meta.get_docfield("Proforma Invoice", "naming_series"); + frappe.db + .get_single_value("Selling Settings", "default_proforma_print_format") + .then((default_print_format) => { + this.build_dialog(frm, pending, series ? series.options : "", default_print_format); + }); + }); + }, + + build_dialog(frm, pending, series_options, default_print_format) { + const dialog = new frappe.ui.Dialog({ + title: __("Create Proforma Invoice"), + size: "large", + fields: [ + { + fieldname: "naming_series", + fieldtype: "Select", + label: __("Series"), + options: series_options, + default: (series_options || "").split("\n")[0], + reqd: 1, + }, + { fieldname: "cb_series", fieldtype: "Column Break" }, + { + fieldname: "print_format", + fieldtype: "Link", + label: __("Print Format"), + options: "Print Format", + default: default_print_format, + get_query: () => ({ filters: { doc_type: "Sales Order" } }), + }, + { + fieldname: "letter_head", + fieldtype: "Link", + label: __("Letter Head"), + options: "Letter Head", + }, + { fieldname: "items_section", fieldtype: "Section Break", label: __("Items") }, + { + fieldname: "items", + fieldtype: "Table", + cannot_add_rows: true, + data: pending.map((row) => ({ + ...row, + qty: flt(row.pending_qty), + qty_summary: `${format_number(row.pending_qty)} / ${format_number(row.so_qty)}`, + })), + fields: [ + { + fieldname: "item_code", + fieldtype: "Data", + label: __("Item"), + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "qty_summary", + fieldtype: "Data", + label: __("Pending"), + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "qty", + fieldtype: "Float", + label: __("Qty"), + in_list_view: 1, + }, + { fieldname: "item_name", fieldtype: "Data", hidden: 1 }, + { fieldname: "so_detail", fieldtype: "Data", hidden: 1 }, + ], + }, + ], + primary_action_label: __("Create"), + primary_action: (values) => this.create(frm, dialog, values), + }); + + dialog.show(); + }, + + create(frm, dialog, values) { + const items = (values.items || []) + .filter((row) => flt(row.qty) > 0) + .map((row) => ({ so_detail: row.so_detail, qty: row.qty })); + + if (!items.length) { + frappe.msgprint(__("Please enter a quantity for at least one item.")); + return; + } + + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.make_proforma_invoice", + args: { + sales_order: frm.doc.name, + items: JSON.stringify(items), + naming_series: values.naming_series, + print_format: values.print_format, + letter_head: values.letter_head, + }, + freeze: true, + freeze_message: __("Creating Proforma Invoice..."), + callback: (r) => { + if (!r.message) return; + dialog.hide(); + frappe.show_alert({ + message: __("Proforma Invoice {0} created", [r.message]), + indicator: "green", + }); + frm.reload_doc(); + }, + }); + }, + + render_list(frm) { + // EmbeddedList is a lazy bundle (not on the eager desk bundle), so pull it in first. + frappe.require("embedded_list.bundle.js", () => this.build_list(frm)); + }, + + build_list(frm) { + const wrapper = frm.get_field("proforma_html").$wrapper.empty(); + const list = new frappe.ui.EmbeddedList({ + wrapper, + doctype: "Proforma Invoice", + filters: { sales_order: frm.doc.name, docstatus: 1 }, + fields: ["name", "proforma_date", "grand_total", "status", "proforma_pdf", "sent_on", "currency"], + order_by: "creation desc", + empty_message: __("No proforma invoices yet."), + // Show the Proforma tab only once at least one proforma exists for this order. + after_render() { + erpnext.proforma.toggle_tab(frm, (this._all_data || []).length > 0); + }, + columns: [ + { + label: __("Proforma No"), + type: "link", + fieldname: "name", + route: (row) => ["Form", "Proforma Invoice", row.name], + }, + { + label: __("Date"), + fieldname: "proforma_date", + render: (row) => frappe.datetime.str_to_user(row.proforma_date), + }, + { + label: __("Grand Total"), + fieldname: "grand_total", + render: (row) => format_currency(row.grand_total, row.currency), + }, + { + label: __("Status"), + type: "badge", + fieldname: "status", + color: (row) => (row.status === "Issued" ? "green" : "gray"), + }, + { + type: "actions", + actions: [ + { + icon: "printer", + label: __("View PDF"), + action: (row) => row.proforma_pdf && window.open(row.proforma_pdf, "_blank"), + }, + { + icon: "mail", + label: __("Send Email"), + action: (row, refresh) => this.send_email(frm, row.name, refresh), + }, + ], + }, + ], + }); + list.refresh(); + }, + + send_email(frm, proforma_name, refresh) { + frappe.prompt( + [ + { + fieldname: "recipients", + fieldtype: "Data", + label: __("Recipients"), + reqd: 1, + default: frm.doc.contact_email, + description: __("Comma separated email addresses"), + }, + ], + (values) => { + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.send_proforma_email", + args: { proforma_name, recipients: values.recipients }, + freeze: true, + callback: () => { + frappe.show_alert({ message: __("Proforma emailed"), indicator: "green" }); + (refresh || (() => this.render_list(frm)))(); + }, + }); + }, + __("Send Proforma Invoice"), + __("Send") + ); + }, +}); diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 0b00a2d8613..9c8ed1bf649 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -178,6 +178,8 @@ "column_break_yvzv", "inter_company_order_reference", "party_account_currency", + "proforma_tab", + "proforma_html", "connections_tab" ], "fields": [ @@ -1526,6 +1528,17 @@ "fieldname": "column_break_49", "fieldtype": "Column Break" }, + { + "fieldname": "proforma_tab", + "fieldtype": "Tab Break", + "hidden": 1, + "label": "Proforma" + }, + { + "fieldname": "proforma_html", + "fieldtype": "HTML", + "label": "Proforma Invoices" + }, { "fieldname": "connections_tab", "fieldtype": "Tab Break", diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py index ea9c8d2f96e..f6767c533d0 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py +++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py @@ -24,6 +24,7 @@ def get_data(): "label": _("Fulfillment"), "items": ["Sales Invoice", "Pick List", "Delivery Note", "Maintenance Visit"], }, + {"label": _("Proforma"), "items": ["Proforma Invoice"]}, {"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]}, {"label": _("Projects"), "items": ["Project"]}, {"label": _("Manufacturing"), "items": ["Work Order", "BOM", "Blanket Order"]},