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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
205
ns_app/public/js/customer_statements.js
Normal file
205
ns_app/public/js/customer_statements.js
Normal 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();
|
||||
};
|
||||
Reference in New Issue
Block a user