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:
2026-08-02 19:26:19 +05:30
co-authored by Claude Sonnet 5
parent d7f5bfe5f5
commit ebe67cc410
20 changed files with 1401 additions and 226 deletions
@@ -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
]
]
}
@@ -0,0 +1,237 @@
/**
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SearchSelectOption {
value: string;
label: string;
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
* that aren't all visible in the collapsed label. */
searchText?: string;
}
/**
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
* it is impractical (e.g. hundreds of loan accounts) — a text box that filters the option list
* as you type, instead of a native dropdown you can only scroll through. Selection only commits
* on click; typing without picking an option reverts the box to the last committed selection on
* blur, so the bound ngModel value never drifts from an actual, valid option.
*
* The option list is positioned `fixed` and anchored to the input's own on-screen coordinates
* (recomputed on open/scroll/resize) rather than `absolute` inside the component — most of this
* app's cards use `overflow: hidden` for rounded corners, which would otherwise clip a dropdown
* that extends past the card's edge.
*
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
*/
@Component({
selector: 'mifosx-search-select',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchSelectComponent),
multi: true
}
],
template: `
<div class="inv-search-select">
<input
#inputEl
type="text"
class="inv-search-select__input"
[value]="query"
[placeholder]="placeholder"
[disabled]="disabled"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open) {
<ul
class="inv-search-select__list"
[style.top.px]="listTop"
[style.left.px]="listLeft"
[style.width.px]="listWidth"
(mousedown)="$event.preventDefault()"
>
@for (o of filteredOptions; track o.value) {
<li
class="inv-search-select__option"
[class.inv-search-select__option--active]="o.value === value"
(click)="selectOption(o)"
>
{{ o.label }}
</li>
} @empty {
<li class="inv-search-select__empty">No matches</li>
}
</ul>
}
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
* component's own template, so its look is re-declared here rather than inherited. */
:host {
display: block;
width: 100%;
}
.inv-search-select {
position: relative;
width: 100%;
}
.inv-search-select__input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-search-select__list {
position: fixed;
z-index: 2000;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
}
.inv-search-select__option {
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: #0d1b2a;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inv-search-select__option:hover,
.inv-search-select__option--active {
background: #eef2ff;
}
.inv-search-select__empty {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
`
]
})
export class SearchSelectComponent implements ControlValueAccessor {
@Input() options: SearchSelectOption[] = [];
@Input() placeholder = 'Search…';
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
value = '';
query = '';
open = false;
disabled = false;
listTop = 0;
listLeft = 0;
listWidth = 0;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
get filteredOptions(): SearchSelectOption[] {
const q = this.query.trim().toLowerCase();
if (!q) {
return this.options;
}
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
}
private labelFor(value: string): string {
return this.options.find((o) => o.value === value)?.label ?? '';
}
writeValue(v: string): void {
this.value = v ?? '';
this.query = this.labelFor(this.value);
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/** Anchors the fixed-position list to the input's current on-screen box. */
private updateListPosition(): void {
const el = this.inputEl?.nativeElement;
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
this.listTop = rect.bottom + 4;
this.listLeft = rect.left;
this.listWidth = rect.width;
}
@HostListener('window:scroll')
@HostListener('window:resize')
onWindowChange(): void {
if (this.open) {
this.updateListPosition();
}
}
onInput(event: Event): void {
this.query = (event.target as HTMLInputElement).value;
this.open = true;
this.updateListPosition();
}
onFocus(): void {
this.open = true;
this.updateListPosition();
}
onBlur(): void {
this.open = false;
this.onTouched();
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
this.query = this.labelFor(this.value);
}
selectOption(o: SearchSelectOption): void {
this.value = o.value;
this.query = o.label;
this.open = false;
this.onChangeFn(this.value);
this.onTouched();
}
}