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:
2026-08-02 11:33:01 +05:30
parent 27cdaf7fc2
commit b4608cbbe4
6 changed files with 1737 additions and 23 deletions
@@ -75,7 +75,7 @@
</div>
<div class="cctr-tabs">
@for (g of ['reports', 'analysis', 'classification']; track g) {
@for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
@@ -462,9 +462,14 @@
@if (selectedAccount; as a) {
<div class="pg-card">
<div class="pos-section-label">
{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
<div class="pos-section-label npa-detail-header">
<span
>{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span></span
>
<button type="button" class="pg-btn npa-timeline-trigger" (click)="openTimelineModal()">
<fa-icon icon="history"></fa-icon> Recovery Journey
</button>
</div>
<dl class="npa-detail-grid">
<div class="npa-detail-field">
@@ -1037,6 +1042,548 @@
}
</div>
}
<!-- ===================== 11 RECOVERY MANAGEMENT DASHBOARD ===================== -->
@if (activeReport === 'recovery-dashboard') {
<div class="pg-card">
<div class="pos-section-label">Recovery Management Dashboard — Loan-Type Overdue Check</div>
<p class="pg-sub npa-report-note">
Every loan is checked for "is this cycle's dues fully recovered" the way it's actually due — a Term Loan (EMI)
is checked against its instalment, an Overdrawn (CC/OD/KCC) account against its drawing power and interest
servicing, and a Bullet loan (Gold) is checked once past maturity.
</p>
<div class="inv-kpis npa-kpis--three">
@for (row of recoveryTypeSummary; track row.type) {
<div class="inv-kpi">
<span class="inv-kpi__label">{{ row.label }}</span>
<span class="inv-kpi__value">{{ row.accounts }} accounts</span>
<span class="inv-kpi__sub" [class.npa-warn]="row.overdueCount > 0"
>{{ row.overdueCount }} overdue this cycle</span
>
</div>
}
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Expected vs Actual Recovery — This Cycle, by Loan Type</div>
<table class="pg-table">
<thead>
<tr>
<th>Loan Type</th>
<th class="num">Accounts</th>
<th class="num">Overdue Now</th>
<th class="num">Expected Recovery</th>
<th class="num col-in">Actual Recovery</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of recoveryTypeSummary; track row.type) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.accounts }}</td>
<td class="num">{{ row.overdueCount }}</td>
<td class="num">₹{{ row.expected | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="row.actual - row.expected < 0">
₹{{ row.actual - row.expected | number: '1.0-0' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ allAccounts.length }}</td>
<td class="num">
{{
recoveryTypeSummary[0].overdueCount +
recoveryTypeSummary[1].overdueCount +
recoveryTypeSummary[2].overdueCount
}}
</td>
<td class="num">₹{{ expectedRecoveryTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ actualRecoveryTotal | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="recoveryVarianceTotal < 0">
₹{{ recoveryVarianceTotal | number: '1.0-0' }}
</td>
</tr>
</tfoot>
</table>
<p class="pg-sub npa-report-note">
Overall recovery rate: {{ recoveryPctTotal | number: '1.1-1' }}% of this cycle's expected dues.
</p>
</div>
<div class="pg-card">
<div class="pos-section-label">Loan-wise Expected vs Actual Recovery</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Loan Type</th>
<th class="num">Expected</th>
<th class="num col-in">Actual</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(loanWiseRecoveryAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ repaymentTypeLabel(a.repaymentType) }}</td>
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ actualThisMonth(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="recoveryVariance(a) < 0">
₹{{ recoveryVariance(a) | number: '1.0-0' }}
</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="recoveryVariance(a) >= 0"
[class.npa-badge--substandard]="recoveryVariance(a) < 0"
>
{{ recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (loanWiseRecoveryAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(loanWiseRecoveryAccounts) }}–{{ rangeEnd(loanWiseRecoveryAccounts) }} of
{{ loanWiseRecoveryAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, loanWiseRecoveryAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(loanWiseRecoveryAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(loanWiseRecoveryAccounts)"
(click)="goToPage(currentPage + 1, loanWiseRecoveryAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 12 DAY-WISE OVERDUE REPORT ===================== -->
@if (activeReport === 'daywise-overdue') {
<div class="pg-card">
<div class="pos-section-label">Day-wise Overdue Report — Ageing Buckets</div>
<table class="pg-table">
<thead>
<tr>
<th>Overdue Bucket</th>
<th class="num">Accounts</th>
<th class="num">Outstanding</th>
<th class="num col-out">Overdue Amount</th>
</tr>
</thead>
<tbody>
@for (b of overdueAgeingBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.outstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ b.overdueAmt | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Overdue Accounts — Day-wise, Marked by SMA Stage</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Overdue Days</th>
<th class="num">Outstanding</th>
<th class="num col-out">Overdue Amount</th>
</tr>
</thead>
<tbody>
@for (a of paged(overdueDaysAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">{{ a.overdueDays }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No overdue accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (overdueDaysAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(overdueDaysAccounts) }}–{{ rangeEnd(overdueDaysAccounts) }} of
{{ overdueDaysAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, overdueDaysAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdueDaysAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(overdueDaysAccounts)"
(click)="goToPage(currentPage + 1, overdueDaysAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 13 DAY-WISE OVERDRAWN REPORT ===================== -->
@if (activeReport === 'daywise-overdrawn') {
<div class="pg-card">
<div class="pos-section-label">
Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts ({{ overdrawnBreachCount }} of
{{ overdrawnAccounts.length }} breaching Drawing Power)
</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Drawing Power</th>
<th class="num">Outstanding</th>
<th class="num col-out">Excess Over DP</th>
<th class="num">Days Overdrawn</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(overdrawnAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
<td class="num">{{ overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!overdrawnBreachOf(a)"
[class.npa-badge--substandard]="overdrawnBreachOf(a)"
>
{{ overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No Overdrawn accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (overdrawnAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(overdrawnAccounts) }}–{{ rangeEnd(overdrawnAccounts) }} of
{{ overdrawnAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, overdrawnAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdrawnAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(overdrawnAccounts)"
(click)="goToPage(currentPage + 1, overdrawnAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
@if (activeReport === 'agri-gold-value') {
<div class="pg-card">
<div class="pos-section-label">Agriculture / Gold Market Value Report — Current Security Value vs Loan</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Outstanding</th>
<th class="num">Collateral Value (At Sanction)</th>
<th class="num">Market Value (Now)</th>
<th class="num">Coverage %</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(agriGoldAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.collateralValue | number: '1.0-0' }}</td>
<td
class="num"
[class.col-in]="a.collateralMarketValue >= a.collateralValue"
[class.col-out]="a.collateralMarketValue < a.collateralValue"
>
₹{{ a.collateralMarketValue | number: '1.0-0' }}
</td>
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!undersecuredOf(a)"
[class.npa-badge--substandard]="undersecuredOf(a)"
>
{{ undersecuredOf(a) ? 'Undersecured' : 'Adequate' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No Agriculture / Gold accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (agriGoldAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(agriGoldAccounts) }}–{{ rangeEnd(agriGoldAccounts) }} of
{{ agriGoldAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, agriGoldAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(agriGoldAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(agriGoldAccounts)"
(click)="goToPage(currentPage + 1, agriGoldAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 15 EXCEPTION CHECK REPORT ===================== -->
@if (activeReport === 'exception-check') {
<div class="pg-card">
<div class="pos-section-label">
Exception Check — Security Value vs Loan ({{ exceptionAccounts.length }} accounts undersecured)
</div>
<p class="pg-sub npa-report-note">
Every secured loan's current security market value is expected to stay above its outstanding balance. This lists
the exceptions — where it doesn't — so recovery/credit can review before the next classification review.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Market Value</th>
<th class="num col-out">Shortfall</th>
<th class="num">Coverage %</th>
</tr>
</thead>
<tbody>
@for (a of paged(exceptionAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.collateralMarketValue | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ securityShortfall(a) | number: '1.0-0' }}</td>
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No undersecured accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (exceptionAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(exceptionAccounts) }}–{{ rangeEnd(exceptionAccounts) }} of
{{ exceptionAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, exceptionAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(exceptionAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(exceptionAccounts)"
(click)="goToPage(currentPage + 1, exceptionAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
<!-- ===================== Recovery / Collection Journey popup ===================== -->
@if (showTimelineModal && selectedAccount; as a) {
<div class="npa-timeline-backdrop" (click)="closeTimelineModal()">
<div class="npa-timeline-modal" (click)="$event.stopPropagation()">
<header class="npa-timeline-modal__head">
<div class="npa-timeline-modal__title">
<span class="npa-timeline-modal__eyebrow">Recovery / Collection Journey</span>
<h2>{{ a.accountNo }} — {{ a.customerName }}</h2>
<div class="npa-timeline-modal__meta">
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
<span class="npa-timeline-modal__branch">{{ a.branchName }}</span>
<span class="npa-timeline-modal__count">{{ a.collectionTimeline.length }} touchpoints</span>
</div>
</div>
<button type="button" class="npa-timeline-modal__close" (click)="closeTimelineModal()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="npa-timeline-modal__body">
@if (a.collectionTimeline.length) {
<ol class="npa-timeline">
@for (e of a.collectionTimeline; track e.date + e.type) {
<li [class]="'npa-timeline-item npa-timeline-item--' + timelineEventTone(e.type)">
<span class="npa-timeline-item__icon"><fa-icon [icon]="timelineEventIcon(e.type)"></fa-icon></span>
<div class="npa-timeline-item__body">
<div class="npa-timeline-item__row">
<span class="npa-timeline-item__type">{{ e.type }}</span>
<span class="npa-timeline-item__date">{{ e.date }}</span>
</div>
<p class="npa-timeline-item__remarks">{{ e.remarks }}</p>
<span class="npa-timeline-item__channel">{{ e.channel }}</span>
</div>
</li>
}
</ol>
} @else {
<div class="npa-timeline-empty">
<fa-icon icon="check-circle"></fa-icon>
<p>No collection activity — this account has never gone overdue.</p>
</div>
}
</div>
</div>
</div>
}
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -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 [];
}