/** * 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 | 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 { 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); } }