Files
cbsnexora/src/app/npa/npa-reports/npa-reports.component.ts
T

1760 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { SearchSelectComponent, SearchSelectOption } from '../search-select/search-select.component';
import {
LOAN_ACCOUNTS,
BRANCHES,
REPORT_META,
GROUP_LABELS,
DEFAULT_REPORT_ID,
LoanAccount,
NpaStage,
ReportGroupId,
STAGE_ORDER,
STAGE_LABELS,
currentStage,
previousStage,
isNpaStage,
isSmaStage,
provisionRatePct,
provisionRequired,
provisionHeld,
interestReversed,
totalRecovered,
movementLabel,
stageBadgeClass,
npaSinceDate,
daysSinceNpa,
interestRecoveredCash,
undebitedInterestBalance,
todayDDMMYYYY,
LegalActionStatus,
LEGAL_ACTION_LABELS,
RepaymentType,
REPAYMENT_TYPE_LABELS,
isBulletMatured,
isBulletOverdue,
isOverdrawnBreach,
expectedRecoveryThisMonth,
actualRecoveryThisMonth,
securityCoverageRatio,
isUndersecured,
RecoveryEvent,
RecoveryEventType
} from '../npa-data';
interface StageSummaryRow {
stage: NpaStage;
label: string;
count: number;
outstanding: number;
provisionReq: number;
}
interface BranchSummaryRow {
branchCode: string;
branchName: string;
totalAccounts: number;
totalAdvances: number;
npaCount: number;
npaOutstanding: number;
provisionReq: number;
gnpaRatio: number;
}
interface RecoveryRow {
accountNo: string;
customerName: string;
branchName: string;
productName: string;
npaCategory: string;
npaCategoryClass: string;
date: string;
amount: number;
mode: string;
/** What the account was contractually due to pay this month (EMI / interest-servicing /
* bullet), the same "Target" basis used by the Recovery Management Dashboard — shown
* alongside the actual recovered amount so a desk can see over/under-achievement per entry. */
targetRecovery: number;
}
interface RepaymentTypeSummaryRow {
type: RepaymentType;
label: string;
accounts: number;
overdueCount: number;
expected: number;
actual: number;
}
interface AgeingBucketRow {
label: string;
count: number;
outstanding: number;
overdueAmt: number;
}
/**
* The legacy "NPA" mega-menu — screens across NPA Reports / NPA Analysis / NPA Classification /
* Recovery Management — collapsed into one route-param-driven component so every menu item lands
* on its own URL (/npa/reports/:reportId) with nothing left unwired. Every figure is derived from
* the single LOAN_ACCOUNTS book in npa-data.ts, so a count on one report always agrees with another.
*/
@Component({
selector: 'mifosx-npa-reports',
templateUrl: './npa-reports.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../npa-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
FontAwesomeModule,
SearchSelectComponent
]
})
export class NpaReportsComponent implements OnInit, OnDestroy {
readonly reportMeta = REPORT_META;
readonly groupLabels = GROUP_LABELS;
readonly branches = BRANCHES;
readonly stageOrder = STAGE_ORDER;
readonly stageLabels = STAGE_LABELS;
asOnDate = todayDDMMYYYY();
activeGroup: ReportGroupId = 'reports';
activeReport = DEFAULT_REPORT_ID;
/** True for a Head Office login — sees a branch selector + the consolidated "All Branches"
* view with a branch-wise break-up. A branch-level login is locked to its own branch, the
* same split used by the Cash/Clearing/Transfer Summary report. */
isHeadOffice = false;
/** The logged-in user's own branch/office name, from real session credentials. */
currentOfficeName = 'Branch Office';
/** Set when a branch-level login's officeName matches one of the demo branches below. */
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
/** Pagination — shared across whichever report is currently on screen, since only one
* report's table is ever visible at a time. Reset to page 1 whenever the underlying list
* can change size (report switch, branch switch, Generate). */
readonly pageSize = 20;
currentPage = 1;
selectedAccountNo = '';
/** Opens the Recovery / Collection Journey popup for whichever account is currently selected. */
showTimelineModal = false;
newRecovery = {
accountNo: '',
date: this.asOnDate,
amount: null as number | null,
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;
private sub?: Subscription;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
ngOnInit(): void {
this.sub = this.route.paramMap.subscribe((params) => {
const id = params.get('reportId') || DEFAULT_REPORT_ID;
const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[3];
this.activeReport = meta.id;
this.activeGroup = meta.group;
this.currentPage = 1;
});
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) {
// Head Office starts on the consolidated view and can drill into any branch.
this.branchFilter = '';
} else {
// Match the logged-in office's real name to one of the demo branches, so each branch's
// login sees only its own book. Falls back to the first demo branch (still labelled with
// the user's real office name) if no branch name matches.
this.ownBranchCode =
this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
}
const firstNpa = this.npaAccounts[0];
this.selectedAccountNo = firstNpa ? firstNpa.accountNo : this.allAccounts[0]?.accountNo || '';
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
/** Name of the branch/scope currently being viewed, for display in the header. */
get scopeName(): string {
if (!this.isHeadOffice) {
return this.currentOfficeName;
}
return this.branchFilter
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
: 'All Branches (Consolidated)';
}
setBranch(code: string): void {
this.branchFilter = code;
this.currentPage = 1;
}
// -------------------- Pagination (shared by every report's table) --------------------
paged<T>(items: T[]): T[] {
const start = (this.currentPage - 1) * this.pageSize;
return items.slice(start, start + this.pageSize);
}
pageCount(items: unknown[]): number {
return Math.max(1, Math.ceil(items.length / this.pageSize));
}
rangeStart(items: unknown[]): number {
return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
}
rangeEnd(items: unknown[]): number {
return Math.min(this.currentPage * this.pageSize, items.length);
}
goToPage(page: number, items: unknown[]): void {
this.currentPage = Math.min(Math.max(1, page), this.pageCount(items));
}
goToReport(reportId: string): void {
this.router.navigate([
'/npa/reports',
reportId
]);
}
groupOf(group: string) {
return this.reportMeta.filter((m) => m.group === group);
}
reportLabel(id: string): string {
return this.reportMeta.find((m) => m.id === id)?.label || id;
}
stage(a: LoanAccount): NpaStage {
return currentStage(a);
}
prevStage(a: LoanAccount): NpaStage {
return previousStage(a);
}
stageLabel(stage: NpaStage): string {
return STAGE_LABELS[stage];
}
badgeClass(stage: NpaStage): string {
return stageBadgeClass(stage);
}
provisionRate(a: LoanAccount): number {
return provisionRatePct(this.stage(a), a.secured);
}
provisionReq(a: LoanAccount): number {
return provisionRequired(a);
}
provisionHeldAmt(a: LoanAccount): number {
return provisionHeld(a);
}
interestRev(a: LoanAccount): number {
return interestReversed(a);
}
recovered(a: LoanAccount): number {
return totalRecovered(a);
}
movement(a: LoanAccount): string {
return movementLabel(this.prevStage(a), this.stage(a));
}
npaSince(a: LoanAccount): string {
return npaSinceDate(a);
}
daysSinceNpaOf(a: LoanAccount): number {
return daysSinceNpa(a);
}
interestRecoveredCashOf(a: LoanAccount): number {
return interestRecoveredCash(a);
}
undebitedInterestBalanceOf(a: LoanAccount): number {
return undebitedInterestBalance(a);
}
// -------------------- Recovery Management System --------------------
repaymentTypeLabel(type: RepaymentType): string {
return REPAYMENT_TYPE_LABELS[type];
}
expectedThisMonth(a: LoanAccount): number {
return expectedRecoveryThisMonth(a);
}
actualThisMonth(a: LoanAccount): number {
return actualRecoveryThisMonth(a);
}
recoveryVariance(a: LoanAccount): number {
return this.actualThisMonth(a) - this.expectedThisMonth(a);
}
/** The per-repayment-type "is this account currently irregular" check the note calls for:
* an EMI shortfall this cycle, a drawing-power breach, or a matured-unpaid bullet loan. */
isTypeOverdue(a: LoanAccount): boolean {
if (a.repaymentType === 'OVERDRAWN') {
return isOverdrawnBreach(a);
}
if (a.repaymentType === 'BULLET') {
return isBulletOverdue(a);
}
return a.overdueDays > 0;
}
overdrawnBreachOf(a: LoanAccount): boolean {
return isOverdrawnBreach(a);
}
excessOverDp(a: LoanAccount): number {
return Math.max(0, a.outstandingBalance - a.drawingPower);
}
bulletMaturedOf(a: LoanAccount): boolean {
return isBulletMatured(a);
}
bulletOverdueOf(a: LoanAccount): boolean {
return isBulletOverdue(a);
}
coverageRatio(a: LoanAccount): number {
return securityCoverageRatio(a);
}
undersecuredOf(a: LoanAccount): boolean {
return isUndersecured(a);
}
securityShortfall(a: LoanAccount): number {
return Math.max(0, a.outstandingBalance - a.collateralMarketValue);
}
/** CSS class for a collection-timeline event — "Payment Received" reads positive, formal
* escalation steps (notice/agent) read as an alert, everything else is neutral follow-up. */
/** Groups a timeline event into one of 3 visual tones for the popup's icon badge —
* informational follow-up, an escalation step, or an actual payment. */
timelineEventTone(type: RecoveryEventType): 'positive' | 'alert' | 'neutral' {
if (type === 'Payment Received') {
return 'positive';
}
if (type === 'Formal Overdue Letter' || type === 'Recovery Agent Assigned' || type === 'Demand Notice') {
return 'alert';
}
return 'neutral';
}
timelineEventIcon(type: RecoveryEventType): string {
switch (type) {
case 'SMS Reminder':
return 'comment-dots';
case 'IVR / Auto Call':
case 'Tele-calling':
case 'Overdue Call':
return 'phone';
case 'Field Visit':
return 'map-marker-alt';
case 'Formal Overdue Letter':
return 'envelope';
case 'Recovery Agent Assigned':
return 'user-tie';
case 'Demand Notice':
return 'file-signature';
case 'Payment Received':
return 'check-circle';
}
}
openTimelineModal(): void {
this.showTimelineModal = true;
}
closeTimelineModal(): void {
this.showTimelineModal = false;
}
legalActionLabel(status: LegalActionStatus): string {
return LEGAL_ACTION_LABELS[status];
}
legalActionClass(status: LegalActionStatus): string {
return status === 'SARFAESI_INITIATED' || status === 'SUIT_FILED' ? 'col-out' : '';
}
updateLegalAction(a: LoanAccount, status: LegalActionStatus): void {
a.legalActionStatus = status;
this.toast(`${this.legalActionLabel(status)} — recorded for ${a.accountNo}.`);
}
isNpaStageOf(a: LoanAccount): boolean {
return isNpaStage(this.stage(a));
}
get allAccounts(): LoanAccount[] {
return this.branchFilter ? LOAN_ACCOUNTS.filter((a) => a.branchCode === this.branchFilter) : LOAN_ACCOUNTS;
}
get npaAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => isNpaStage(this.stage(a)));
}
get smaAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => isSmaStage(this.stage(a)));
}
get probableNpaAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => this.stage(a) === 'SMA2').sort((a, b) => b.overdueDays - a.overdueDays);
}
get movedAccounts(): LoanAccount[] {
return this.allAccounts
.filter((a) => this.prevStage(a) !== this.stage(a))
.sort((a, b) => STAGE_ORDER.indexOf(this.stage(b)) - STAGE_ORDER.indexOf(this.stage(a)));
}
get recoveryAccounts(): LoanAccount[] {
return this.npaAccounts.filter((a) => a.recoveries.length > 0);
}
/** '' = All Account Types. One shared filter drives both the "NPA Account" picker above (so
* it's quicker to find the right account when adding a new entry) and the Recovery Register
* below — a single control instead of two that could drift out of sync. */
recoveryProductFilter = '';
get recoveryProductOptions(): string[] {
return [...new Set(this.npaAccounts.map((a) => a.productName))].sort();
}
setRecoveryProductFilter(productName: string): void {
this.recoveryProductFilter = productName;
this.currentPage = 1;
if (!this.recoveryOptions.some((a) => a.accountNo === this.newRecovery.accountNo)) {
this.newRecovery.accountNo = '';
}
}
/** NPA accounts selectable in "Record a Recovery", narrowed by the Account Type filter. */
get recoveryOptions(): LoanAccount[] {
return this.npaAccounts.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter);
}
/** recoveryOptions, reshaped for the searchable NPA Account picker — matches on account no,
* customer name and branch, not just the collapsed label text. */
get recoveryAccountSearchOptions(): SearchSelectOption[] {
return this.recoveryOptions.map((a) => ({
value: a.accountNo,
label: `${a.accountNo} — ${a.customerName} (${this.stageLabel(this.stage(a))})`,
searchText: `${a.accountNo} ${a.customerName} ${a.productName} ${a.branchName}`
}));
}
get recoveryRows(): RecoveryRow[] {
return this.recoveryAccounts
.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter)
.flatMap((a) =>
a.recoveries.map((r) => ({
accountNo: a.accountNo,
customerName: a.customerName,
branchName: a.branchName,
productName: a.productName,
npaCategory: this.stageLabel(this.stage(a)),
npaCategoryClass: this.badgeClass(this.stage(a)),
date: r.date,
amount: r.amount,
mode: r.mode,
targetRecovery: expectedRecoveryThisMonth(a)
}))
)
.sort((a, b) => b.amount - a.amount);
}
/** The accounts behind recoveryRows, honouring the Account Type filter — used to total the
* monthly Target once per account rather than once per recovery transaction (an account with
* two recovery entries must not have its target counted twice). */
get recoveryFilteredAccounts(): LoanAccount[] {
return this.recoveryAccounts.filter(
(a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter
);
}
get recoveryTargetTotal(): number {
return this.recoveryFilteredAccounts.reduce((s, a) => s + expectedRecoveryThisMonth(a), 0);
}
get recoveryActualTotal(): number {
return this.recoveryRows.reduce((s, r) => s + r.amount, 0);
}
// -------------------- Portfolio-wide KPIs (shown on every screen) --------------------
get totalAdvances(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
get gnpaAmount(): number {
return this.npaAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
get gnpaRatio(): number {
return this.totalAdvances ? (this.gnpaAmount / this.totalAdvances) * 100 : 0;
}
get totalNpaProvisionHeld(): number {
return this.npaAccounts.reduce((s, a) => s + this.provisionHeldAmt(a), 0);
}
get netNpaAmount(): number {
return Math.max(0, this.gnpaAmount - this.totalNpaProvisionHeld);
}
get nnpaRatio(): number {
return this.totalAdvances ? (this.netNpaAmount / this.totalAdvances) * 100 : 0;
}
get totalRecoveredAmount(): number {
return this.npaAccounts.reduce((s, a) => s + this.recovered(a), 0);
}
// -------------------- Undebited Interest Recovery Report --------------------
get undebitedInterestAccruedTotal(): number {
return this.npaAccounts.reduce((s, a) => s + this.interestRev(a), 0);
}
get interestRecoveredCashTotal(): number {
return this.npaAccounts.reduce((s, a) => s + this.interestRecoveredCashOf(a), 0);
}
get undebitedInterestBalanceTotal(): number {
return this.npaAccounts.reduce((s, a) => s + this.undebitedInterestBalanceOf(a), 0);
}
get restructuredAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => a.isRestructured);
}
get restructuredOutstanding(): number {
return this.restructuredAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
// -------------------- Recovery Management Dashboard --------------------
get recoveryTypeSummary(): RepaymentTypeSummaryRow[] {
const types: RepaymentType[] = [
'EMI',
'OVERDRAWN',
'BULLET'
];
return types.map((type) => {
const accts = this.allAccounts.filter((a) => a.repaymentType === type);
return {
type,
label: REPAYMENT_TYPE_LABELS[type],
accounts: accts.length,
overdueCount: accts.filter((a) => this.isTypeOverdue(a)).length,
expected: accts.reduce((s, a) => s + this.expectedThisMonth(a), 0),
actual: accts.reduce((s, a) => s + this.actualThisMonth(a), 0)
};
});
}
get expectedRecoveryTotal(): number {
return this.allAccounts.reduce((s, a) => s + this.expectedThisMonth(a), 0);
}
get actualRecoveryTotal(): number {
return this.allAccounts.reduce((s, a) => s + this.actualThisMonth(a), 0);
}
get recoveryVarianceTotal(): number {
return this.actualRecoveryTotal - this.expectedRecoveryTotal;
}
get recoveryPctTotal(): number {
return this.expectedRecoveryTotal ? (this.actualRecoveryTotal / this.expectedRecoveryTotal) * 100 : 0;
}
get loanWiseRecoveryAccounts(): LoanAccount[] {
return [...this.allAccounts].sort((a, b) => this.recoveryVariance(a) - this.recoveryVariance(b));
}
// -------------------- Day-wise Overdue Report --------------------
get overdueAgeingBuckets(): AgeingBucketRow[] {
const buckets: Array<{ label: string; test: (days: number) => boolean }> = [
{ label: 'Current (0 Days)', test: (d) => d === 0 },
{ label: '1–30 Days (SMA-0)', test: (d) => d >= 1 && d <= 30 },
{ label: '31–60 Days (SMA-1)', test: (d) => d >= 31 && d <= 60 },
{ label: '61–90 Days (SMA-2)', test: (d) => d >= 61 && d <= 90 },
{ label: '90+ Days (NPA)', test: (d) => d > 90 }
];
return buckets.map((b) => {
const accts = this.allAccounts.filter((a) => b.test(a.overdueDays));
return {
label: b.label,
count: accts.length,
outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0),
overdueAmt: accts.reduce((s, a) => s + a.overdueAmount, 0)
};
});
}
get overdueDaysAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => a.overdueDays > 0).sort((a, b) => b.overdueDays - a.overdueDays);
}
// -------------------- Day-wise Overdrawn Report --------------------
get overdrawnAccounts(): LoanAccount[] {
return this.allAccounts
.filter((a) => a.repaymentType === 'OVERDRAWN')
.sort((a, b) => this.excessOverDp(b) - this.excessOverDp(a));
}
get overdrawnBreachCount(): number {
return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length;
}
// -------------------- Agriculture / Gold Market Value Report --------------------
get agriGoldAccounts(): LoanAccount[] {
return this.allAccounts
.filter((a) => a.productCode === '05' || a.productCode === '06')
.sort((a, b) => this.coverageRatio(a) - this.coverageRatio(b));
}
// -------------------- Exception Check Report --------------------
get exceptionAccounts(): LoanAccount[] {
return this.allAccounts
.filter((a) => this.undersecuredOf(a))
.sort((a, b) => this.securityShortfall(b) - this.securityShortfall(a));
}
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
/** SMA-0/1/2 are early-warning "special mention" buckets, not NPA stages — this report only
* shows Standard vs the actual NPA classification ladder (Sub-Standard through Loss). SMA
* accounts still have their own dedicated Day-wise Overdue report. */
get stageSummary(): StageSummaryRow[] {
return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).map((stage) => {
const accts = this.allAccounts.filter((a) => this.stage(a) === stage);
return {
stage,
label: STAGE_LABELS[stage],
count: accts.length,
outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0),
provisionReq: accts.reduce((s, a) => s + this.provisionReq(a), 0)
};
});
}
get branchSummary(): BranchSummaryRow[] {
return this.branches.map((b) => {
const all = LOAN_ACCOUNTS.filter((a) => a.branchCode === b.code);
const npa = all.filter((a) => isNpaStage(this.stage(a)));
const totalAdvances = all.reduce((s, a) => s + a.outstandingBalance, 0);
const npaOutstanding = npa.reduce((s, a) => s + a.outstandingBalance, 0);
return {
branchCode: b.code,
branchName: b.name,
totalAccounts: all.length,
totalAdvances,
npaCount: npa.length,
npaOutstanding,
provisionReq: npa.reduce((s, a) => s + this.provisionReq(a), 0),
gnpaRatio: totalAdvances ? (npaOutstanding / totalAdvances) * 100 : 0
};
});
}
get grandTotalAccounts(): number {
return this.stageSummary.reduce((s, row) => s + row.count, 0);
}
get grandTotalOutstanding(): number {
return this.stageSummary.reduce((s, row) => s + row.outstanding, 0);
}
get grandTotalProvision(): number {
return this.stageSummary.reduce((s, row) => s + row.provisionReq, 0);
}
get bankWideTotalAccounts(): number {
return LOAN_ACCOUNTS.length;
}
// -------------------- NPA with Provision and Required Provision --------------------
get provisionRows(): LoanAccount[] {
return [...this.allAccounts].sort((a, b) => this.provisionReq(b) - this.provisionReq(a));
}
get provisionRequiredTotal(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0);
}
get provisionHeldTotal(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionHeldAmt(a), 0);
}
get provisionShortfallTotal(): number {
return this.provisionRequiredTotal - this.provisionHeldTotal;
}
shortfall(a: LoanAccount): number {
return this.provisionReq(a) - this.provisionHeldAmt(a);
}
// -------------------- NPA Account Detail Report --------------------
get selectedAccount(): LoanAccount | undefined {
return LOAN_ACCOUNTS.find((a) => a.accountNo === this.selectedAccountNo);
}
searchAccount(): void {
this.showTimelineModal = false;
const a = this.selectedAccount;
if (!a) {
this.toast('No account found with that number.');
return;
}
this.toast(`Loaded full NPA details for ${a.accountNo} — ${a.customerName}.`);
}
// -------------------- NPA Recovery Report: add recovery entry --------------------
addRecovery(): void {
const acc = LOAN_ACCOUNTS.find((a) => a.accountNo === this.newRecovery.accountNo);
if (!acc || !this.newRecovery.amount || this.newRecovery.amount <= 0) {
this.toast('Select an NPA account and enter a valid recovery amount.');
return;
}
acc.recoveries = [
{ date: this.newRecovery.date || this.asOnDate, amount: this.newRecovery.amount, mode: this.newRecovery.mode },
...acc.recoveries
];
this.toast(`Recovery of ₹${this.newRecovery.amount.toLocaleString('en-IN')} recorded against ${acc.accountNo}.`);
this.newRecovery = { accountNo: '', date: this.asOnDate, amount: null, mode: 'Cash Deposit' };
}
sumOutstanding(accounts: LoanAccount[]): number {
return accounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
sumOverdue(accounts: LoanAccount[]): number {
return accounts.reduce((s, a) => s + a.overdueAmount, 0);
}
sumInterest(accounts: LoanAccount[]): number {
return accounts.reduce((s, a) => s + this.interestRev(a), 0);
}
generate(): void {
this.currentPage = 1;
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 Report',
title: 'NPA Report — 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: this.recoveryProductFilter
? `${subtitle} · Account Type: ${this.recoveryProductFilter}`
: subtitle,
headers: [
'Date',
'Account Type',
'NPA Category',
'Account No',
'Customer',
'Branch',
'Mode',
'Target Recovery',
'Recovered Amount (Actual)'
],
widths: [
12,
18,
14,
16,
22,
16,
18,
16,
20
],
rows: [
...this.recoveryRows.map((r) => [
r.date,
r.productName,
r.npaCategory,
r.accountNo,
r.customerName,
r.branchName,
r.mode,
r.targetRecovery,
r.amount
]),
[
'Total (Target once per account · Actual across all entries)',
'',
'',
'',
'',
'',
'',
this.recoveryTargetTotal,
this.recoveryActualTotal
]
]
}
];
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
])
},
{
sheetName: 'Collection Timeline',
title: a ? `Recovery / Collection Journey — ${a.accountNo}` : 'Recovery / Collection Journey',
subtitle,
headers: [
'Date',
'Event',
'Channel',
'Remarks'
],
widths: [
14,
20,
24,
46
],
rows: (a?.collectionTimeline || []).map((e) => [
e.date,
e.type,
e.channel,
e.remarks
])
}
];
}
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 'undebited-interest':
return [
{
sheetName: 'Undebited Interest',
title: 'Undebited Interest Recovery Report — Memorandum Interest (IRAC Norms)',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Stage',
'NPA Since',
'Days NPA',
'Rate %',
'Undebited Interest Accrued',
'Interest Recovered (Cash)',
'Undebited Interest Balance'
],
widths: [
16,
22,
16,
14,
12,
10,
10,
22,
20,
22
],
rows: [
...this.npaAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
this.stageLabel(this.stage(a)),
this.npaSince(a),
this.daysSinceNpaOf(a),
a.interestRate,
this.interestRev(a),
this.interestRecoveredCashOf(a),
this.undebitedInterestBalanceOf(a)
]),
[
`Total (${this.npaAccounts.length} accounts)`,
'',
'',
'',
'',
'',
'',
this.undebitedInterestAccruedTotal,
this.interestRecoveredCashTotal,
this.undebitedInterestBalanceTotal
]
]
}
];
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),
''
]
]
}
];
case 'recovery-dashboard':
return [
{
sheetName: 'Recovery Summary',
title: 'Expected vs Actual Recovery — This Cycle, by Loan Type',
subtitle,
headers: [
'Loan Type',
'Accounts',
'Overdue Now',
'Expected Recovery',
'Actual Recovery',
'Variance'
],
widths: [
20,
12,
12,
18,
18,
16
],
rows: [
...this.recoveryTypeSummary.map((row) => [
row.label,
row.accounts,
row.overdueCount,
row.expected,
row.actual,
row.actual - row.expected
]),
[
'Total',
this.allAccounts.length,
this.recoveryTypeSummary.reduce((s, row) => s + row.overdueCount, 0),
this.expectedRecoveryTotal,
this.actualRecoveryTotal,
this.recoveryVarianceTotal
]
]
},
{
sheetName: 'Loan-wise Recovery',
title: 'Loan-wise Expected vs Actual Recovery',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Loan Type',
'Expected',
'Actual',
'Variance',
'Status'
],
widths: [
16,
22,
16,
18,
16,
16,
16,
12
],
rows: this.loanWiseRecoveryAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
this.repaymentTypeLabel(a.repaymentType),
this.expectedThisMonth(a),
this.actualThisMonth(a),
this.recoveryVariance(a),
this.recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall'
])
}
];
case 'daywise-overdue':
return [
{
sheetName: 'Ageing Buckets',
title: 'Day-wise Overdue Report — Ageing Buckets',
subtitle,
headers: [
'Overdue Bucket',
'Accounts',
'Outstanding',
'Overdue Amount'
],
widths: [
22,
12,
18,
18
],
rows: this.overdueAgeingBuckets.map((b) => [
b.label,
b.count,
b.outstanding,
b.overdueAmt
])
},
{
sheetName: 'Overdue Accounts',
title: 'Overdue Accounts — Day-wise, Marked by SMA Stage',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Stage',
'Overdue Days',
'Outstanding',
'Overdue Amount'
],
widths: [
16,
22,
16,
16,
14,
12,
16,
18
],
rows: this.overdueDaysAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
this.stageLabel(this.stage(a)),
a.overdueDays,
a.outstandingBalance,
a.overdueAmount
])
}
];
case 'daywise-overdrawn':
return [
{
sheetName: 'Overdrawn Accounts',
title: 'Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Drawing Power',
'Outstanding',
'Excess Over DP',
'Days Overdrawn',
'Status'
],
widths: [
16,
22,
16,
16,
16,
16,
16,
14,
14
],
rows: this.overdrawnAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
a.drawingPower,
a.outstandingBalance,
this.excessOverDp(a),
this.overdrawnBreachOf(a) ? a.overdueDays : 0,
this.overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit'
])
}
];
case 'agri-gold-value':
return [
{
sheetName: 'Agri-Gold Value',
title: 'Agriculture / Gold Market Value Report — Current Security Value vs Loan',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Outstanding',
'Collateral Value (At Sanction)',
'Market Value (Now)',
'Coverage %',
'Status'
],
widths: [
16,
22,
16,
16,
16,
22,
18,
12,
14
],
rows: this.agriGoldAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
a.outstandingBalance,
a.collateralValue,
a.collateralMarketValue,
Number(this.coverageRatio(a).toFixed(0)),
this.undersecuredOf(a) ? 'Undersecured' : 'Adequate'
])
}
];
case 'exception-check':
return [
{
sheetName: 'Exception Check',
title: 'Exception Check — Security Value vs Loan',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Product',
'Stage',
'Outstanding',
'Market Value',
'Shortfall',
'Coverage %'
],
widths: [
16,
22,
16,
16,
14,
16,
16,
16,
12
],
rows: this.exceptionAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.productName,
this.stageLabel(this.stage(a)),
a.outstandingBalance,
a.collateralMarketValue,
this.securityShortfall(a),
Number(this.coverageRatio(a).toFixed(0))
])
}
];
default:
return [];
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}