feat(statements): Customer-form button, consolidated UI, disable-fee toggle

Replace customer_list.js with customer_statements.js (loaded globally),
which adds the statement UI to both entry points:
- Customer list: 'Generate Statements' multi-select action.
- Customer form: 'Generate Statement' button for a single customer.

Both open a popup with a 'Generate late payment fee' checkbox (default on)
that maps to generate_statements(skip_late_fee), so fee billing can be
turned off per run. Shared generate/print helpers live on ns_statements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 19:52:15 -04:00
parent c20dd18287
commit 46967208e9
3 changed files with 207 additions and 157 deletions

View File

@@ -7,7 +7,8 @@ app_license = "MIT"
# Load on every page
app_include_js = [
"/assets/ns_app/js/customer_quick_entry.js"
"/assets/ns_app/js/customer_quick_entry.js",
"/assets/ns_app/js/customer_statements.js"
]
# Load on Sales Invoice form
@@ -15,11 +16,6 @@ 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"
}
# Ensure custom fields exist after every migrate
after_migrate = "ns_app.setup.after_migrate"

View File

@@ -1,151 +0,0 @@
// 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)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [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();
}

View File

@@ -0,0 +1,205 @@
// Customer Statements: generate printable, one-page-per-customer account
// statements formatted for a window envelope. Two entry points share the same
// generate/print helpers — a multi-select action on the Customer list and a
// single-customer button on the Customer form. Loaded globally so both the
// list view and the form can reach the shared `ns_statements` helpers.
frappe.provide("ns_statements");
// ── Entry point: Customer list ───────────────────────────────────────────────
frappe.listview_settings["Customer"] = {
onload(listview) {
listview.page.add_inner_button(__("Generate Statements"), () => {
ns_statements.pick_and_generate();
});
}
};
// ── Entry point: Customer form ───────────────────────────────────────────────
frappe.ui.form.on("Customer", {
refresh(frm) {
if (frm.is_new()) return;
frm.add_custom_button(__("Generate Statement"), () => {
ns_statements.generate_for_customer(frm.doc.name);
});
}
});
// ── Shared: call the backend and open the printable document ─────────────────
ns_statements.run = function (customers, skip_late_fee) {
frappe.call({
method: "ns_app.api.statements.generate_statements",
args: { customers, skip_late_fee: skip_late_fee ? 1 : 0 },
freeze: true,
freeze_message: __("Generating statements..."),
callback(r) {
if (!r.message || !r.message.html) return;
ns_statements.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"
});
}
}
});
};
ns_statements.open_print_window = function (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();
};
// ── List flow: pick customers with overdue invoices, then generate ───────────
ns_statements.pick_and_generate = function () {
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;
}
ns_statements._selection_dialog(rows);
}
});
};
ns_statements._selection_dialog = function (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:45vh; 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>`
},
{
fieldtype: "Check",
fieldname: "generate_late_fee",
label: __("Generate late payment fee"),
default: 1,
description: __("Bills a late-fee invoice (once per customer this month) for overdue balances.")
}
],
primary_action_label: __("Generate Statements"),
primary_action() {
const customers = [...selected];
if (!customers.length) {
frappe.msgprint(__("Select at least one customer."));
return;
}
const gen_fee = dialog.get_value("generate_late_fee");
const proceed = () => {
dialog.hide();
ns_statements.run(customers, !gen_fee);
};
if (gen_fee) {
frappe.confirm(
__("Generate statements for {0} customer(s)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [customers.length]),
proceed
);
} else {
proceed();
}
}
});
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();
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();
});
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();
});
};
// ── Form flow: single customer, with the same fee toggle ─────────────────────
ns_statements.generate_for_customer = function (customer) {
const d = new frappe.ui.Dialog({
title: __("Generate Statement"),
fields: [
{
fieldtype: "HTML",
options: `<p>${__("Generate an account statement for <b>{0}</b>.", [frappe.utils.escape_html(customer)])}</p>`
},
{
fieldtype: "Check",
fieldname: "generate_late_fee",
label: __("Generate late payment fee"),
default: 1,
description: __("Bills a late-fee invoice (once this month) for overdue balances.")
}
],
primary_action_label: __("Generate"),
primary_action(values) {
d.hide();
ns_statements.run([customer], !values.generate_late_fee);
}
});
d.show();
};