feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers
- Correct Investment portfolio allocation and Loan Origination product-mix targets against RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing RBI-mandated vs board-policy rows (Excel export included). - Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary reflects a realistic spread across FD/Govt Security/Non-SLR/Bond. - Add From/To period-based Interest Calculation & Post (with GL selection for both posting and receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug where a posted period kept re-appearing as due since accrual wasn't tracked per-holding. - Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date fields. - NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only). - NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared Account Type filter driving both the account picker and the register, and a searchable (fixed-position, not clipped by card overflow) account select for long lists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -264,16 +264,22 @@
|
||||
<div class="pos-section-label">Record a Recovery Against an NPA Account</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>* NPA Account</span>
|
||||
<select [(ngModel)]="newRecovery.accountNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (a of recoveryOptions; track a.accountNo) {
|
||||
<option [value]="a.accountNo">
|
||||
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
|
||||
</option>
|
||||
<span>Account Type</span>
|
||||
<select [ngModel]="recoveryProductFilter" (ngModelChange)="setRecoveryProductFilter($event)">
|
||||
<option value="">-- All Account Types --</option>
|
||||
@for (p of recoveryProductOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* NPA Account</span>
|
||||
<mifosx-search-select
|
||||
[options]="recoveryAccountSearchOptions"
|
||||
[(ngModel)]="newRecovery.accountNo"
|
||||
placeholder="Search account no. or customer name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Recovery Date</span>
|
||||
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
|
||||
@@ -299,45 +305,66 @@
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
|
||||
gnpaAmount | number: '1.0-0'
|
||||
}}
|
||||
GNPA outstanding
|
||||
<div
|
||||
class="pos-section-label"
|
||||
style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<span>
|
||||
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
|
||||
gnpaAmount | number: '1.0-0'
|
||||
}}
|
||||
GNPA outstanding
|
||||
</span>
|
||||
@if (recoveryProductFilter) {
|
||||
<span class="pg-chip">Filtered: {{ recoveryProductFilter }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="pg-sub" style="padding: 8px 16px 12px">
|
||||
Target Recovery is the account's contractual due this month (EMI / interest-servicing / bullet amount) — same
|
||||
basis as the Recovery Management Dashboard.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Account Type</th>
|
||||
<th>NPA Category</th>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Mode</th>
|
||||
<th class="num col-in">Recovered Amount</th>
|
||||
<th class="num">Target Recovery</th>
|
||||
<th class="num col-in">Recovered Amount (Actual)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
|
||||
<tr>
|
||||
<td>{{ r.date }}</td>
|
||||
<td>{{ r.productName }}</td>
|
||||
<td>
|
||||
<span [class]="r.npaCategoryClass">{{ r.npaCategory }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.accountNo }}</span>
|
||||
</td>
|
||||
<td>{{ r.customerName }}</td>
|
||||
<td>{{ r.branchName }}</td>
|
||||
<td>{{ r.mode }}</td>
|
||||
<td class="num">₹{{ r.targetRecovery | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td>
|
||||
<td colspan="9" class="pg-empty">No recoveries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="5">Total Recovered</td>
|
||||
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td>
|
||||
<td colspan="7">Total (Target once per account · Actual across all entries shown)</td>
|
||||
<td class="num">₹{{ recoveryTargetTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ recoveryActualTotal | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -79,9 +80,16 @@ 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 {
|
||||
@@ -119,7 +127,8 @@ interface AgeingBucketRow {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
FontAwesomeModule
|
||||
FontAwesomeModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
@@ -457,25 +466,75 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
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;
|
||||
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
|
||||
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);
|
||||
@@ -616,8 +675,11 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// -------------------- 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.map((stage) => {
|
||||
return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).map((stage) => {
|
||||
const accts = this.allAccounts.filter((a) => this.stage(a) === stage);
|
||||
return {
|
||||
stage,
|
||||
@@ -649,15 +711,15 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get grandTotalAccounts(): number {
|
||||
return this.allAccounts.length;
|
||||
return this.stageSummary.reduce((s, row) => s + row.count, 0);
|
||||
}
|
||||
|
||||
get grandTotalOutstanding(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
||||
return this.stageSummary.reduce((s, row) => s + row.outstanding, 0);
|
||||
}
|
||||
|
||||
get grandTotalProvision(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0);
|
||||
return this.stageSummary.reduce((s, row) => s + row.provisionReq, 0);
|
||||
}
|
||||
|
||||
get bankWideTotalAccounts(): number {
|
||||
@@ -865,39 +927,53 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
{
|
||||
sheetName: 'NPA Recovery',
|
||||
title: 'NPA Recovery Register',
|
||||
subtitle,
|
||||
subtitle: this.recoveryProductFilter
|
||||
? `${subtitle} · Account Type: ${this.recoveryProductFilter}`
|
||||
: subtitle,
|
||||
headers: [
|
||||
'Date',
|
||||
'Account Type',
|
||||
'NPA Category',
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Mode',
|
||||
'Recovered Amount'
|
||||
'Target Recovery',
|
||||
'Recovered Amount (Actual)'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
18,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
18,
|
||||
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 Recovered',
|
||||
'Total (Target once per account · Actual across all entries)',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.totalRecoveredAmount
|
||||
'',
|
||||
'',
|
||||
this.recoveryTargetTotal,
|
||||
this.recoveryActualTotal
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user