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
@@ -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
]
]
}