diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index 0b794a03c3..0fec4f346e 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -166,6 +166,104 @@ export default class BulkOperations { dialog.show(); } + consolidated_print(docs) { + const print_settings = frappe.model.get_doc(":Print Settings", "Print Settings"); + const allow_print_for_draft = cint(print_settings.allow_print_for_draft); + const is_submittable = frappe.model.is_submittable(this.doctype); + const allow_print_for_cancelled = cint(print_settings.allow_print_for_cancelled); + const letterheads = this.get_letterhead_options(); + const MAX_CONSOLIDATED_LIMIT = 100; + + const valid_docs = docs + .filter((doc) => { + return ( + !is_submittable || + doc.docstatus === 1 || + (allow_print_for_cancelled && doc.docstatus == 2) || + (allow_print_for_draft && doc.docstatus == 0) || + frappe.user.has_role("Administrator") + ); + }) + .map((doc) => doc.name); + + const invalid_docs = docs.filter((doc) => !valid_docs.includes(doc.name)); + + if (invalid_docs.length > 0) { + frappe.msgprint(__("You selected Draft or Cancelled documents")); + return; + } + + if (valid_docs.length === 0) { + frappe.msgprint(__("Select atleast 1 record for printing")); + return; + } + + if (valid_docs.length > MAX_CONSOLIDATED_LIMIT) { + frappe.msgprint( + __("You can only consolidate up to {0} documents at a time", [ + MAX_CONSOLIDATED_LIMIT, + ]) + ); + return; + } + + const dialog = new frappe.ui.Dialog({ + title: __("Consolidated Print"), + fields: [ + { + fieldtype: "HTML", + options: `

${__( + "All selected documents will be rendered in a single continuous layout without page breaks between them." + )}

`, + }, + { + fieldtype: "Select", + label: __("Letter Head"), + fieldname: "letter_sel", + options: letterheads, + default: letterheads[0], + }, + { + fieldtype: "Select", + label: __("Print Format"), + fieldname: "print_sel", + options: frappe.meta.get_print_formats(this.doctype), + default: frappe.get_meta(this.doctype).default_print_format, + }, + ], + }); + + dialog.set_primary_action(__("Print"), (args) => { + if (!args) return; + const default_print_format = frappe.get_meta(this.doctype).default_print_format; + const with_letterhead = args.letter_sel == __("No Letterhead") ? 0 : 1; + const print_format = args.print_sel ? args.print_sel : default_print_format; + const names_json = JSON.stringify(valid_docs); + const letterhead = args.letter_sel; + + const w = window.open( + "/consolidated_printview?" + + "doctype=" + + encodeURIComponent(this.doctype) + + "&names=" + + encodeURIComponent(names_json) + + "&format=" + + encodeURIComponent(print_format) + + "&no_letterhead=" + + (with_letterhead ? "0" : "1") + + "&letterhead=" + + encodeURIComponent(letterhead) + + "&trigger_print=1" + ); + + if (!w) { + frappe.msgprint(__("Please enable pop-ups")); + } + dialog.hide(); + }); + dialog.show(); + } + get_letterhead_options() { const letterhead_options = [__("No Letterhead")]; frappe.call({ diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 2c28a24063..a34b28ac57 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -2279,6 +2279,14 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { }; }; + const bulk_consolidated_printing = () => { + return { + label: __("Consolidated Print", null, "Button in list view actions menu"), + action: () => bulk_operations.consolidated_print(this.get_checked_items()), + standard: true, + }; + }; + const bulk_delete = () => { return { label: __("Delete", null, "Button in list view actions menu"), @@ -2569,6 +2577,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { // bulk printing if (frappe.model.can_print(doctype)) { actions_menu_items.push(bulk_printing()); + actions_menu_items.push(bulk_consolidated_printing()); } // bulk submit diff --git a/frappe/www/consolidated_printview.html b/frappe/www/consolidated_printview.html new file mode 100644 index 0000000000..e75c3343a3 --- /dev/null +++ b/frappe/www/consolidated_printview.html @@ -0,0 +1,81 @@ + + + + + + {{ title }} + + {{ include_style('print.bundle.css') }} + {% if print_style %} + + {% endif %} + + + + + + + +{%- if doc_count -%} + +{%- endif -%} + diff --git a/frappe/www/consolidated_printview.py b/frappe/www/consolidated_printview.py new file mode 100644 index 0000000000..85d84fb1cd --- /dev/null +++ b/frappe/www/consolidated_printview.py @@ -0,0 +1,118 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + +import json + +import frappe +from frappe import _ +from frappe.core.doctype.access_log.access_log import make_access_log +from frappe.utils import cint +from frappe.utils.jinja_globals import is_rtl +from frappe.www.printview import ( + get_print_format_doc, + get_print_style, + get_rendered_template, + set_link_titles, + trigger_print_script, +) + +no_cache = 1 + +MAX_CONSOLIDATED_PRINT_DOCS = 100 + + +def get_context(context): + """Build context for consolidated (multi-document, no page-break) print.""" + doctype = frappe.form_dict.get("doctype") + names_json = frappe.form_dict.get("names") + + if not doctype or not names_json: + return { + "body": "

Error

Parameters doctype and names are required.

", + "print_style": "", + "title": "Error", + "lang": "en", + "layout_direction": "ltr", + "doc_count": 0, + } + + try: + names = json.loads(names_json) + except (json.JSONDecodeError, TypeError): + frappe.throw(_("Invalid names parameter: must be a JSON array of document names")) + + if not isinstance(names, list) or len(names) == 0: + frappe.throw(_("At least one document name is required")) + + if len(names) > MAX_CONSOLIDATED_PRINT_DOCS: + frappe.throw( + _("Cannot consolidate more than {0} documents at a time").format(MAX_CONSOLIDATED_PRINT_DOCS) + ) + + format_name = frappe.form_dict.get("format") or None + no_letterhead = frappe.form_dict.get("no_letterhead", 0) + letterhead = frappe.form_dict.get("letterhead") or None + trigger_print = cint(frappe.form_dict.get("trigger_print", 0)) + + meta = frappe.get_meta(doctype) + print_format = get_print_format_doc(format_name, meta=meta) + + all_html_parts = [] + + for name in names: + try: + doc = frappe.get_lazy_doc(doctype, name) + set_link_titles(doc) + html = _get_doc_print_html(doc, print_format, meta, no_letterhead, letterhead) + all_html_parts.append(html) + except frappe.PermissionError: + frappe.log_error( + title="Consolidated Print Permission Error", + message=f"No permission to print {doctype} {name}", + ) + except Exception: + frappe.log_error( + title="Consolidated Print Render Error", + message=f"Error rendering {doctype} {name}", + ) + + separator = '' + body = separator.join(f'
{html}
' for html in all_html_parts) + + if trigger_print: + body += trigger_print_script + + make_access_log( + doctype=doctype, + document=", ".join(names[:10]), + file_type="PDF", + method="Print", + page=f"Consolidated Print Format: {getattr(print_format, 'name', 'Standard')} ({len(names)} docs)", + ) + + return { + "body": body, + "print_style": get_print_style(frappe.form_dict.get("style"), print_format), + "title": f"Consolidated Print — {doctype}", + "lang": frappe.local.lang, + "layout_direction": "rtl" if is_rtl() else "ltr", + "doctype": doctype, + "doc_count": len(all_html_parts), + } + + +def _get_doc_print_html(doc, print_format, meta, no_letterhead, letterhead): + """Return rendered HTML for a single document, handling both standard and beta print formats.""" + if print_format and print_format.get("print_format_builder_beta"): + from frappe.utils.weasyprint import get_html + + return get_html(doctype=doc.doctype, name=doc.name, print_format=print_format.name) + + return get_rendered_template( + doc=doc, + print_format=print_format, + meta=meta, + no_letterhead=no_letterhead, + letterhead=letterhead, + trigger_print=False, + )