- 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>
250 lines
7.5 KiB
TypeScript
250 lines
7.5 KiB
TypeScript
/**
|
|
* 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 } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { RouterLink } from '@angular/router';
|
|
import {
|
|
ALL_HOLDINGS,
|
|
ALLOCATION_TARGET_IS_RBI_LINKED,
|
|
INSTRUMENT_LABELS,
|
|
InstrumentType,
|
|
InvestmentHolding,
|
|
TARGET_ALLOCATION_PCT,
|
|
accruedInterest,
|
|
daysToMaturity,
|
|
maturityAmount
|
|
} from '../investment-data';
|
|
import { exportWorkbook } from '../investment-export';
|
|
|
|
interface InstrumentSummary {
|
|
type: InstrumentType;
|
|
label: string;
|
|
route: string;
|
|
count: number;
|
|
faceValue: number;
|
|
accruedInterest: number;
|
|
maturityValue: number;
|
|
}
|
|
|
|
interface AllocationRow {
|
|
type: InstrumentType;
|
|
label: string;
|
|
targetPct: number;
|
|
actualPct: number;
|
|
actualValue: number;
|
|
variancePct: number;
|
|
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
|
|
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
|
|
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
|
|
isRbiLinked: boolean;
|
|
}
|
|
|
|
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
|
|
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
|
|
@Component({
|
|
selector: 'mifosx-investment-dashboard',
|
|
templateUrl: './investment-dashboard.component.html',
|
|
styleUrls: [
|
|
'../../cashier/cashier-shared.scss',
|
|
'../investment-shared.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
RouterLink
|
|
]
|
|
})
|
|
export class InvestmentDashboardComponent {
|
|
asOnDate = '';
|
|
generating = false;
|
|
toastMsg = '';
|
|
toastVisible = false;
|
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
|
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
|
{ type: 'FD', route: '/investment/fd' },
|
|
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
|
{ type: 'NON_SLR', route: '/investment/bonds' },
|
|
{ type: 'BOND', route: '/investment/bonds' },
|
|
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
|
|
{ type: 'SHARE', route: '/investment/share' }
|
|
];
|
|
|
|
constructor() {
|
|
const d = new Date();
|
|
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
|
}
|
|
|
|
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
|
|
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
|
|
}
|
|
|
|
get summaries(): InstrumentSummary[] {
|
|
return this.types.map(({ type, route }) => {
|
|
const holdings = this.holdingsOf(type);
|
|
return {
|
|
type,
|
|
label: INSTRUMENT_LABELS[type],
|
|
route,
|
|
count: holdings.length,
|
|
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
|
|
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
|
|
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
|
|
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
|
|
* a treasury desk can't rebalance to the exact decimal every day. */
|
|
get allocationVsTarget(): AllocationRow[] {
|
|
const total = this.totalFaceValue;
|
|
return this.types.map(({ type }) => {
|
|
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
|
|
const actualPct = total ? (value / total) * 100 : 0;
|
|
const targetPct = TARGET_ALLOCATION_PCT[type];
|
|
const variancePct = +(actualPct - targetPct).toFixed(1);
|
|
const statusLabel: AllocationRow['statusLabel'] =
|
|
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
|
|
return {
|
|
type,
|
|
label: INSTRUMENT_LABELS[type],
|
|
targetPct,
|
|
actualPct: +actualPct.toFixed(1),
|
|
actualValue: value,
|
|
variancePct,
|
|
statusLabel,
|
|
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
|
|
};
|
|
});
|
|
}
|
|
|
|
get totalHoldings(): number {
|
|
return ALL_HOLDINGS.length;
|
|
}
|
|
|
|
get totalFaceValue(): number {
|
|
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
|
|
}
|
|
|
|
get totalAccruedInterest(): number {
|
|
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
|
|
}
|
|
|
|
get totalMaturityValue(): number {
|
|
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
|
|
}
|
|
|
|
get activeCount(): number {
|
|
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
|
|
}
|
|
|
|
get maturedCount(): number {
|
|
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
|
|
}
|
|
|
|
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
|
|
get maturingSoon(): InvestmentHolding[] {
|
|
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
|
|
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
|
|
);
|
|
}
|
|
|
|
daysLeft(h: InvestmentHolding): number {
|
|
return daysToMaturity(h, this.asOnDate);
|
|
}
|
|
|
|
instrumentLabel(type: InstrumentType): string {
|
|
return INSTRUMENT_LABELS[type];
|
|
}
|
|
|
|
async exportExcel(): Promise<void> {
|
|
this.generating = true;
|
|
try {
|
|
await exportWorkbook(
|
|
[
|
|
{
|
|
sheetName: 'Instrument Breakup',
|
|
title: 'Investment Portfolio — Instrument-wise Break-up',
|
|
subtitle: `As on ${this.asOnDate}`,
|
|
headers: [
|
|
'Instrument Type',
|
|
'Holdings',
|
|
'Face Value',
|
|
'Accrued Interest',
|
|
'Maturity Value'
|
|
],
|
|
widths: [
|
|
26,
|
|
12,
|
|
18,
|
|
18,
|
|
18
|
|
],
|
|
rows: this.summaries.map((s) => [
|
|
s.label,
|
|
s.count,
|
|
s.faceValue,
|
|
s.accruedInterest,
|
|
s.maturityValue
|
|
])
|
|
},
|
|
{
|
|
sheetName: 'Portfolio Allocation',
|
|
title: 'Portfolio Allocation — Target vs Actual',
|
|
subtitle: `As on ${this.asOnDate}`,
|
|
headers: [
|
|
'Instrument Type',
|
|
'Target Basis',
|
|
'Target %',
|
|
'Actual %',
|
|
'Actual Value',
|
|
'Variance %',
|
|
'Status'
|
|
],
|
|
widths: [
|
|
26,
|
|
20,
|
|
12,
|
|
12,
|
|
18,
|
|
12,
|
|
18
|
|
],
|
|
rows: this.allocationVsTarget.map((a) => [
|
|
a.label,
|
|
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
|
|
a.targetPct,
|
|
a.actualPct,
|
|
a.actualValue,
|
|
a.variancePct,
|
|
a.statusLabel
|
|
])
|
|
}
|
|
],
|
|
`Investment-Dashboard-${this.asOnDate}`
|
|
);
|
|
this.toast('Investment Dashboard exported.');
|
|
} catch (err) {
|
|
console.error('Investment Dashboard export failed', err);
|
|
this.toast('Could not generate the workbook — please retry.');
|
|
} finally {
|
|
this.generating = false;
|
|
}
|
|
}
|
|
|
|
private toast(msg: string): void {
|
|
this.toastMsg = msg;
|
|
this.toastVisible = true;
|
|
if (this.toastTimer) {
|
|
clearTimeout(this.toastTimer);
|
|
}
|
|
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
|
}
|
|
}
|