feat(npa): add Recovery Management System reports + Recovery Journey popup
Adds loan-type-aware recovery tracking (EMI/Overdrawn/Bullet) with a Recovery Management Dashboard, Day-wise Overdue/Overdrawn reports, an Agriculture/Gold market-value report, and a security-value exception check — all derived from new repaymentType/emiAmount/drawingPower/maturityDate/collateralMarketValue fields on LoanAccount, wired into Export to Excel and the NPA mega-menu. Also replaces the always-visible Recovery/Collection Journey card on the Account Detail Report with a "Recovery Journey" popup: a date-led, icon-coded timeline of every SMS/call/visit/notice and payment run against an account.
This commit is contained in:
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -41,7 +42,18 @@ import {
|
||||
undebitedInterestBalance,
|
||||
todayDDMMYYYY,
|
||||
LegalActionStatus,
|
||||
LEGAL_ACTION_LABELS
|
||||
LEGAL_ACTION_LABELS,
|
||||
RepaymentType,
|
||||
REPAYMENT_TYPE_LABELS,
|
||||
isBulletMatured,
|
||||
isBulletOverdue,
|
||||
isOverdrawnBreach,
|
||||
expectedRecoveryThisMonth,
|
||||
actualRecoveryThisMonth,
|
||||
securityCoverageRatio,
|
||||
isUndersecured,
|
||||
RecoveryEvent,
|
||||
RecoveryEventType
|
||||
} from '../npa-data';
|
||||
|
||||
interface StageSummaryRow {
|
||||
@@ -72,11 +84,27 @@ interface RecoveryRow {
|
||||
mode: string;
|
||||
}
|
||||
|
||||
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 — 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.
|
||||
* 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',
|
||||
@@ -90,7 +118,8 @@ interface RecoveryRow {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
RouterModule,
|
||||
FontAwesomeModule
|
||||
]
|
||||
})
|
||||
export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
@@ -123,6 +152,8 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
currentPage = 1;
|
||||
|
||||
selectedAccountNo = '';
|
||||
/** Opens the Recovery / Collection Journey popup for whichever account is currently selected. */
|
||||
showTimelineModal = false;
|
||||
|
||||
newRecovery = {
|
||||
accountNo: '',
|
||||
@@ -283,6 +314,106 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
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];
|
||||
}
|
||||
@@ -395,6 +526,95 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
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 --------------------
|
||||
get stageSummary(): StageSummaryRow[] {
|
||||
return STAGE_ORDER.map((stage) => {
|
||||
@@ -471,6 +691,7 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
searchAccount(): void {
|
||||
this.showTimelineModal = false;
|
||||
const a = this.selectedAccount;
|
||||
if (!a) {
|
||||
this.toast('No account found with that number.');
|
||||
@@ -897,6 +1118,29 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
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
|
||||
])
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1157,6 +1401,272 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
];
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user