feat(npa): add branch-wise NPA module with realistic data, pagination and recovery actions
Adds a full NPA (Non-Performing Assets) module mirroring the legacy CBS NPA mega-menu: NPA Reports, NPA Analysis, and NPA Classification screens over a 640-account loan book classified per RBI IRAC norms (Standard through Loss). Same Head-Office/branch-wise access pattern as the Cash/Clearing/ Transfer summary, 20-row pagination on every large table, and a Recovery Action workflow (demand notice, SARFAESI, suit filing, OTS) on the Account Detail screen. Fixes several data-generation bugs so every account stays internally consistent regardless of when it's viewed: disbursement date can no longer predate the account's own overdue history, last-payment date now reflects the most recent recovery entry, the quarter-ago snapshot can't imply more overdue days than the loan's age allowed, and provision held is capped at the account's outstanding balance. Also fixes a text-overflow/dropdown-arrow overlap on long select options (Investment + NPA shared form styles), and marks the GL "Financial Statements" menu items that are already wired to a working dialog.
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 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 { Subscription } from 'rxjs';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
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,
|
||||
todayDDMMYYYY,
|
||||
LegalActionStatus,
|
||||
LEGAL_ACTION_LABELS
|
||||
} 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;
|
||||
date: string;
|
||||
amount: number;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy "NPA" mega-menu — 9 screens across NPA Reports / NPA Analysis / NPA Classification —
|
||||
* 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
|
||||
]
|
||||
})
|
||||
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 = '';
|
||||
|
||||
newRecovery = {
|
||||
accountNo: '',
|
||||
date: this.asOnDate,
|
||||
amount: null as number | null,
|
||||
mode: 'Cash Deposit'
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
get recoveryOptions(): LoanAccount[] {
|
||||
return this.npaAccounts;
|
||||
}
|
||||
|
||||
get recoveryRows(): RecoveryRow[] {
|
||||
return this.recoveryAccounts
|
||||
.flatMap((a) =>
|
||||
a.recoveries.map((r) => ({
|
||||
accountNo: a.accountNo,
|
||||
customerName: a.customerName,
|
||||
branchName: a.branchName,
|
||||
date: r.date,
|
||||
amount: r.amount,
|
||||
mode: r.mode
|
||||
}))
|
||||
)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
// -------------------- 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);
|
||||
}
|
||||
|
||||
get restructuredAccounts(): LoanAccount[] {
|
||||
return this.allAccounts.filter((a) => a.isRestructured);
|
||||
}
|
||||
|
||||
get restructuredOutstanding(): number {
|
||||
return this.restructuredAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
||||
}
|
||||
|
||||
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
|
||||
get stageSummary(): StageSummaryRow[] {
|
||||
return STAGE_ORDER.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.allAccounts.length;
|
||||
}
|
||||
|
||||
get grandTotalOutstanding(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
||||
}
|
||||
|
||||
get grandTotalProvision(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 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 {
|
||||
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}.`);
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user