Merge pull request #37720 from consolidated-bulk-print-from-list-view

Consolidated bulk print from list view
This commit is contained in:
dataCenter430 2026-03-02 16:29:57 -05:00 committed by GitHub
commit c5349c4ec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 306 additions and 0 deletions

View file

@ -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: `<p class="text-muted small">${__(
"All selected documents will be rendered in a single continuous layout without page breaks between them."
)}</p>`,
},
{
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({

View file

@ -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

View file

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="{{ lang }}" dir="{{ layout_direction }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<meta name="generator" content="frappe">
{{ include_style('print.bundle.css') }}
{% if print_style %}
<style>
{{ print_style }}
</style>
{% endif %}
<style>
/* ── Consolidated Print overrides ───────────────────────────────────────
Remove forced page breaks that the standard per-document template inserts
between its internal "pages". Documents flow continuously. */
.consolidated-doc .page-break {
page-break-after: auto !important;
break-after: auto !important;
}
/* Visual separator shown on screen between documents (hidden when printing) */
.consolidated-doc-separator {
border: none;
border-top: 2px dashed #ccc;
margin: 24px 0;
}
.consolidated-doc:not(:last-child) {
margin-bottom: 20px;
}
@media print {
/* Thin rule visible in the printed output so documents are distinguishable */
.consolidated-doc:not(:last-child)::after {
content: "";
display: block;
border-top: 1px solid #888;
margin: 12px 0;
}
}
</style>
</head>
<body>
<div class="action-banner print-hide">
<a class="p-2" onclick="window.print();">
{{ _("Print") }}
</a>
</div>
<div class="print-format-gutter">
<div class="print-format">
{{ body }}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Mirror the footer-positioning logic from printview.html for each doc
document.querySelectorAll('.consolidated-doc').forEach(function(docEl) {
const pageDiv = docEl.querySelector('.page-break');
if (pageDiv) {
pageDiv.style.display = 'flex';
pageDiv.style.flexDirection = 'column';
}
const footerHtml = docEl.getElementById
? docEl.getElementById('footer-html')
: docEl.querySelector('#footer-html');
if (footerHtml) {
footerHtml.classList.add('hidden-pdf');
footerHtml.classList.remove('visible-pdf');
footerHtml.style.order = 1;
footerHtml.style.marginTop = '20px';
}
});
});
</script>
</body>
{%- if doc_count -%}
<!-- consolidated: {{ doc_count }} documents -->
{%- endif -%}
</html>

View file

@ -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": "<h1>Error</h1><p>Parameters <code>doctype</code> and <code>names</code> are required.</p>",
"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 = '<div class="consolidated-doc-separator print-hide"></div>'
body = separator.join(f'<div class="consolidated-doc">{html}</div>' 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,
)