feat(npa): add Export to Excel across all 9 NPA reports

Every report screen (Interest, Status, Recovery, Summary, Account Detail,
Movement, Provision, Probable NPA, SMA) now has an Export to Excel button
that builds a workbook from the same derived getters the on-screen tables
use, reusing the shared cashier-export.ts builder.
This commit is contained in:
2026-08-01 20:58:42 +05:30
parent 1317a93838
commit 90beea64cb
2 changed files with 599 additions and 0 deletions
@@ -38,6 +38,9 @@
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import {
LOAN_ACCOUNTS,
BRANCHES,
@@ -127,6 +128,9 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
mode: 'Cash Deposit'
};
/** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -479,6 +483,598 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
this.toast(`${this.reportLabel(this.activeReport)} generated as on ${this.asOnDate}.`);
}
// -------------------- Export to Excel (whichever report is currently on screen) --------------------
async exportExcel(): Promise<void> {
this.generating = true;
try {
const sheets = this.buildExportSheets();
await exportWorkbook(sheets, `${this.reportLabel(this.activeReport)}-${this.scopeName}-${this.asOnDate}`);
} catch (err) {
console.error('NPA export failed', err);
} finally {
this.generating = false;
}
}
/** Builds the workbook for whichever report is active — every sheet is built from the same
* derived getters the on-screen tables use, so the export can never disagree with the screen. */
private buildExportSheets(): ExportSheetSpec[] {
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
switch (this.activeReport) {
case 'interest':
return [
{
sheetName: 'NPA Interest',
title: 'NPA Interest — Income Reversed on Non-Performing Accounts',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Stage',
'Outstanding',
'Rate %',
'Overdue Days',
'Interest Reversed'
],
widths: [
16,
22,
16,
16,
14,
16,
10,
12,
18
],
rows: [
...this.npaAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
this.stageLabel(this.stage(a)),
a.outstandingBalance,
a.interestRate,
a.overdueDays,
this.interestRev(a)
]),
[
`Total (${this.npaAccounts.length} accounts)`,
'',
'',
'',
'',
'',
'',
'',
this.sumInterest(this.npaAccounts)
]
]
}
];
case 'status':
return [
{
sheetName: 'NPA Status',
title: 'NPA Status Report — Full Non-Performing Book',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Secured',
'Outstanding',
'Overdue Amount',
'Overdue Days',
'NPA Since',
'Stage',
'Restructured',
'Legal Action',
'Provision Required'
],
widths: [
16,
22,
16,
16,
10,
16,
16,
12,
12,
14,
12,
22,
18
],
rows: this.npaAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
a.secured ? 'Yes' : 'No',
a.outstandingBalance,
a.overdueAmount,
a.overdueDays,
this.npaSince(a),
this.stageLabel(this.stage(a)),
a.isRestructured ? 'Yes' : 'No',
this.legalActionLabel(a.legalActionStatus),
this.provisionReq(a)
])
}
];
case 'recovery':
return [
{
sheetName: 'NPA Recovery',
title: 'NPA Recovery Register',
subtitle,
headers: [
'Date',
'Account No',
'Customer',
'Branch',
'Mode',
'Recovered Amount'
],
widths: [
12,
16,
22,
16,
18,
18
],
rows: [
...this.recoveryRows.map((r) => [
r.date,
r.accountNo,
r.customerName,
r.branchName,
r.mode,
r.amount
]),
[
'Total Recovered',
'',
'',
'',
'',
this.totalRecoveredAmount
]
]
}
];
case 'summary': {
const sheets: ExportSheetSpec[] = [
{
sheetName: 'Stage Summary',
title: 'NPA Summary Report — Stage-wise',
subtitle,
headers: [
'Classification',
'Accounts',
'Outstanding',
'Provision Required'
],
widths: [
22,
12,
18,
18
],
rows: [
...this.stageSummary.map((row) => [
row.label,
row.count,
row.outstanding,
row.provisionReq
]),
[
'Total',
this.grandTotalAccounts,
this.grandTotalOutstanding,
this.grandTotalProvision
]
]
}
];
if (this.isHeadOffice && !this.branchFilter) {
sheets.push({
sheetName: 'Branch Summary',
title: 'Branch-wise NPA Summary — All Branches',
subtitle: `As On: ${this.asOnDate}`,
headers: [
'Branch',
'Total Accounts',
'Total Advances',
'NPA Accounts',
'GNPA Outstanding',
'Provision Required',
'GNPA %'
],
widths: [
20,
14,
18,
14,
18,
18,
10
],
rows: this.branchSummary.map((row) => [
row.branchName,
row.totalAccounts,
row.totalAdvances,
row.npaCount,
row.npaOutstanding,
row.provisionReq,
Number(row.gnpaRatio.toFixed(2))
])
});
}
return sheets;
}
case 'account-detail': {
const a = this.selectedAccount;
const detailRows: (string | number | null)[][] = a
? [
[
'Account No',
a.accountNo
],
[
'Customer',
a.customerName
],
[
'Branch',
a.branchName
],
[
'Product',
a.productName
],
[
'Secured',
a.secured ? 'Yes' : 'No'
],
[
'Guarantor',
a.guarantorName
],
[
'Sanctioned Amount',
a.sanctionedAmount
],
[
'Disbursement Date',
a.disbursementDate
],
[
'Interest Rate %',
a.interestRate
],
[
'Outstanding Balance',
a.outstandingBalance
],
[
'Overdue Amount',
a.overdueAmount
],
[
'Overdue Days',
a.overdueDays
],
[
'NPA Since',
this.npaSince(a)
],
[
'Restructured',
a.isRestructured ? 'Yes' : 'No'
],
[
'Last Payment Date',
a.lastPaymentDate
],
[
'Collateral Value',
a.collateralValue
],
[
'Stage',
this.stageLabel(this.stage(a))
],
[
'Provisioning Rate %',
this.provisionRate(a)
],
[
'Provision Required',
this.provisionReq(a)
],
[
'Provision Held',
this.provisionHeldAmt(a)
],
[
'Interest Reversed',
this.interestRev(a)
],
[
'Total Recovered',
this.recovered(a)
],
[
'Movement (vs Last Quarter)',
this.movement(a)
],
[
'NPA Reason',
a.npaReason
],
[
'Recovery Officer',
a.recoveryOfficer
],
[
'Legal Action Status',
this.legalActionLabel(a.legalActionStatus)
]
]
: [];
return [
{
sheetName: 'Account Detail',
title: a ? `${a.accountNo} — ${a.customerName}` : 'NPA Account Detail Report',
subtitle,
headers: [
'Field',
'Value'
],
widths: [
26,
32
],
rows: detailRows
},
{
sheetName: 'Recovery History',
title: a ? `Recovery History — ${a.accountNo}` : 'Recovery History',
subtitle,
headers: [
'Date',
'Mode',
'Amount'
],
widths: [
14,
20,
18
],
rows: (a?.recoveries || []).map((r) => [
r.date,
r.mode,
r.amount
])
}
];
}
case 'movement':
return [
{
sheetName: 'NPA Movement',
title: 'Classification Movement — Current Quarter vs Previous Quarter',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Previous Stage',
'Current Stage',
'Movement',
'Outstanding'
],
widths: [
16,
22,
16,
16,
16,
26,
16
],
rows: this.movedAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
this.stageLabel(this.prevStage(a)),
this.stageLabel(this.stage(a)),
this.movement(a),
a.outstandingBalance
])
}
];
case 'provision':
return [
{
sheetName: 'NPA Provision',
title: 'Provision Held vs Required — Full Loan Book',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Stage',
'Outstanding',
'Rate %',
'Provision Required',
'Provision Held',
'Shortfall / (Excess)'
],
widths: [
16,
22,
16,
14,
16,
10,
18,
16,
18
],
rows: [
...this.provisionRows.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
this.stageLabel(this.stage(a)),
a.outstandingBalance,
this.provisionRate(a),
this.provisionReq(a),
this.provisionHeldAmt(a),
this.shortfall(a)
]),
[
`Total (${this.provisionRows.length} accounts)`,
'',
'',
'',
'',
'',
this.provisionRequiredTotal,
this.provisionHeldTotal,
this.provisionShortfallTotal
]
]
}
];
case 'probable':
return [
{
sheetName: 'Probable NPA',
title: 'Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Outstanding',
'Overdue Amount',
'Overdue Days',
'Last Payment',
'Follow-up'
],
widths: [
16,
22,
16,
16,
16,
16,
12,
14,
12
],
rows: [
...this.probableNpaAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
a.outstandingBalance,
a.overdueAmount,
a.overdueDays,
a.lastPaymentDate,
a.overdueDays > 75 ? 'Urgent' : 'Monitor'
]),
[
`Total (${this.probableNpaAccounts.length} accounts)`,
'',
'',
'',
this.sumOutstanding(this.probableNpaAccounts),
'',
'',
'',
''
]
]
}
];
case 'sma':
return [
{
sheetName: 'SMA Report',
title: 'Special Mention Accounts — SMA-0 / SMA-1 / SMA-2',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Stage',
'Outstanding',
'Overdue Amount',
'Overdue Days'
],
widths: [
16,
22,
16,
16,
14,
16,
16,
12
],
rows: [
...this.smaAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
this.stageLabel(this.stage(a)),
a.outstandingBalance,
a.overdueAmount,
a.overdueDays
]),
[
`Total (${this.smaAccounts.length} accounts)`,
'',
'',
'',
'',
this.sumOutstanding(this.smaAccounts),
this.sumOverdue(this.smaAccounts),
''
]
]
}
];
default:
return [];
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;