feat(statements): add Customer list "Generate Statements" button

Register doctype_list_js for Customer and add customer_list.js, which
lists customers with overdue invoices in a selection dialog (checkbox
table + select-all), then calls generate_statements and opens the
printable, one-page-per-customer document in a new window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 19:08:14 -04:00
parent 75a9c9d154
commit 9e2e86cead
2 changed files with 156 additions and 0 deletions

View File

@@ -15,6 +15,11 @@ doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}
# Load on Customer list view (adds "Generate Statements" action)
doctype_list_js = {
"Customer": "public/js/customer_list.js"
}
# Fixtures tracked in Git
fixtures = [
{

View File

@@ -0,0 +1,151 @@
// Adds a "Generate Statements" action to the Customer list. It lists customers
// with overdue invoices, lets the user pick which ones, and opens a printable
// (one-page-per-customer) statement document in a new window.
frappe.listview_settings["Customer"] = {
onload(listview) {
listview.page.add_inner_button(__("Generate Statements"), () => {
open_statement_selector();
});
}
};
function open_statement_selector() {
frappe.call({
method: "ns_app.api.statements.get_customers_with_overdue_invoices",
freeze: true,
freeze_message: __("Finding customers with overdue invoices..."),
callback(r) {
const rows = r.message || [];
if (!rows.length) {
frappe.msgprint({
title: __("No Overdue Customers"),
message: __("No customers currently have overdue invoices."),
indicator: "green"
});
return;
}
show_selection_dialog(rows);
}
});
}
function show_selection_dialog(rows) {
const uid = Date.now();
const selected = new Set(rows.map(r => r.customer)); // default: all selected
const body = rows.map(r => `
<tr>
<td class="text-center">
<input type="checkbox" class="cust-check-${uid}"
data-name="${frappe.utils.escape_html(r.customer)}" checked>
</td>
<td>${frappe.utils.escape_html(r.customer_name || r.customer)}</td>
<td class="text-center">${r.overdue_count}</td>
<td class="text-center">${r.max_days_overdue}</td>
<td class="text-right">${format_currency(r.total_outstanding)}</td>
</tr>`).join("");
const dialog = new frappe.ui.Dialog({
title: __("Generate Customer Statements"),
size: "large",
fields: [{
fieldtype: "HTML",
fieldname: "selector",
options: `
<div style="max-height:50vh; overflow:auto;">
<table class="table table-bordered table-sm" style="font-size:13px; margin:0;">
<thead style="position:sticky; top:0; background:#f5f5f5;">
<tr>
<th style="width:36px;">
<input type="checkbox" id="sel_all_${uid}" title="${__("Select all")}" checked>
</th>
<th>${__("Customer")}</th>
<th class="text-center">${__("Overdue Invoices")}</th>
<th class="text-center">${__("Max Days Overdue")}</th>
<th class="text-right">${__("Total Outstanding")}</th>
</tr>
</thead>
<tbody id="sel_body_${uid}">${body}</tbody>
</table>
</div>
<div id="sel_count_${uid}" style="margin-top:8px; font-weight:bold;"></div>`
}],
primary_action_label: __("Generate Statements"),
primary_action() { generate(); }
});
dialog.show();
const update_count = () => {
const el = document.getElementById(`sel_count_${uid}`);
if (el) el.innerText = __("{0} of {1} selected", [selected.size, rows.length]);
};
update_count();
// Row checkboxes (delegated)
dialog.$wrapper.on("change", `.cust-check-${uid}`, function () {
if (this.checked) selected.add(this.dataset.name);
else selected.delete(this.dataset.name);
const all = dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`);
const selAll = document.getElementById(`sel_all_${uid}`);
if (selAll) selAll.checked = [...all].every(c => c.checked);
update_count();
});
// Select-all
dialog.$wrapper.on("change", `#sel_all_${uid}`, function () {
dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`).forEach(cb => {
cb.checked = this.checked;
if (this.checked) selected.add(cb.dataset.name);
else selected.delete(cb.dataset.name);
});
update_count();
});
function generate() {
const customers = [...selected];
if (!customers.length) {
frappe.msgprint(__("Select at least one customer."));
return;
}
frappe.confirm(
__("Generate statements for {0} customer(s)?", [customers.length]),
() => {
frappe.call({
method: "ns_app.api.statements.generate_statements",
args: { customers },
freeze: true,
freeze_message: __("Generating statements..."),
callback(r) {
if (!r.message || !r.message.html) return;
dialog.hide();
open_print_window(r.message.html);
const skipped = (r.message.skipped || []).length;
if (skipped) {
frappe.show_alert({
message: __("Skipped {0} customer(s) with no balance.", [skipped]),
indicator: "orange"
});
}
}
});
}
);
}
}
function open_print_window(html) {
const w = window.open("", "_blank");
if (!w) {
frappe.msgprint(__("Please allow pop-ups to view the statements."));
return;
}
w.document.open();
w.document.write(html);
w.document.close();
}