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:
@@ -192,8 +192,8 @@
|
||||
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Date</span><input type="text" [(ngModel)]="purchaseForm.dealDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Time (24 hr, HH:mm:ss)</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
|
||||
@@ -221,8 +221,8 @@
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Settlement Dt</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.settlementDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Party Code</span>
|
||||
<select [(ngModel)]="purchaseForm.partyCode">
|
||||
@@ -333,8 +333,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Issue Date</span><input type="text" [(ngModel)]="purchaseForm.issueDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
|
||||
/></label>
|
||||
@@ -354,8 +354,8 @@
|
||||
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Meet Dt</span><input type="text" [(ngModel)]="purchaseForm.meetDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
|
||||
/></label>
|
||||
@@ -394,11 +394,11 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Int Date</span><input type="text" [(ngModel)]="purchaseForm.intDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Last Int.dt</span><input type="text" [(ngModel)]="purchaseForm.lastIntDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Accrued Interest (computed)</span
|
||||
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
|
||||
@@ -554,8 +554,8 @@
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Redemption Date *</span><input type="text" [(ngModel)]="redeemDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Redemption Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="redeemDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Redemption Proceeds (computed)</span
|
||||
><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly
|
||||
@@ -575,11 +575,14 @@
|
||||
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Date</span><input type="text" [(ngModel)]="intCalcDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Type</span>
|
||||
<select [(ngModel)]="intCalcType">
|
||||
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
|
||||
<option value="">-- All Types --</option>
|
||||
@for (o of bondTypeOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
@@ -599,11 +602,25 @@
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="intCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
|
||||
</div>
|
||||
@if (intCalcExcludedNpi.length > 0) {
|
||||
<p class="pg-sub" style="padding: 0 16px 12px">
|
||||
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
|
||||
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
|
||||
</p>
|
||||
}
|
||||
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
@@ -694,8 +711,8 @@
|
||||
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Date</span><input type="text" [(ngModel)]="sellForm.dealDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.dealDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss"
|
||||
/></label>
|
||||
@@ -708,8 +725,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Settlement Dt</span><input type="text" [(ngModel)]="sellForm.settlementDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Settlement Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.settlementDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0"
|
||||
/></label>
|
||||
@@ -942,8 +959,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transfer Date *</span><input type="text" [(ngModel)]="goiTransferDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="goiTransferDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button>
|
||||
@@ -958,8 +975,8 @@
|
||||
<div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Valuation Date</span><input type="text" [(ngModel)]="depreciationDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Valuation Date</span><mifosx-date-ddmmyyyy [(ngModel)]="depreciationDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Category</span>
|
||||
<select [(ngModel)]="depreciationCategory">
|
||||
@@ -1043,8 +1060,8 @@
|
||||
><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transfer Date *</span><input type="text" [(ngModel)]="mfTransferDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="mfTransferDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transfer Value (computed)</span
|
||||
><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly
|
||||
@@ -1069,6 +1086,7 @@
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1082,11 +1100,12 @@
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No transactions yet.</td>
|
||||
<td colspan="7" class="pg-empty">No transactions yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
GOVT_SECURITY_HOLDINGS,
|
||||
NON_SLR_HOLDINGS,
|
||||
@@ -30,12 +31,16 @@ import {
|
||||
BOND_RATING_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
AUTHORITY_OPTIONS,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InstrumentType,
|
||||
InterestFreq,
|
||||
INSTRUMENT_LABELS,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
canAccrueInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
@@ -173,7 +178,8 @@ interface BondSellForm {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class BondInvestmentComponent {
|
||||
@@ -224,12 +230,17 @@ export class BondInvestmentComponent {
|
||||
redeemDate = this.asOnDate;
|
||||
redeemRemarks = '';
|
||||
|
||||
// Interest Calculation (legacy: Date / Type / Script / Deal No / ALL + Process)
|
||||
intCalcDate = this.asOnDate;
|
||||
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
|
||||
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
|
||||
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
|
||||
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
intCalcToDate = this.asOnDate;
|
||||
intCalcType: BondType | '' = '';
|
||||
intCalcScript = '';
|
||||
intCalcDealNo = '';
|
||||
intCalcAll = false;
|
||||
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
|
||||
// Write-off
|
||||
writeoffCertNo = '';
|
||||
@@ -647,13 +658,15 @@ export class BondInvestmentComponent {
|
||||
}
|
||||
|
||||
// -------------------- Interest calculation --------------------
|
||||
/** Holdings the Interest Calculation "Process" run would cover, given the filters. */
|
||||
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
|
||||
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
|
||||
* Interest module (it's still collectible on cash basis via Interest Receive there). */
|
||||
get intCalcTargets(): InvestmentHolding[] {
|
||||
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||
if (this.intCalcAll) {
|
||||
return this.holdings.filter((h) => h.status === 'ACTIVE');
|
||||
return base;
|
||||
}
|
||||
return this.holdings.filter((h) => {
|
||||
if (h.status !== 'ACTIVE') return false;
|
||||
return base.filter((h) => {
|
||||
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
|
||||
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
|
||||
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
|
||||
@@ -661,31 +674,62 @@ export class BondInvestmentComponent {
|
||||
});
|
||||
}
|
||||
|
||||
get intCalcExcludedNpi(): InvestmentHolding[] {
|
||||
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
|
||||
}
|
||||
|
||||
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
|
||||
onIntCalcTypeChange(): void {
|
||||
if (this.intCalcType) {
|
||||
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
|
||||
}
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
|
||||
}
|
||||
|
||||
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
|
||||
* Interest Calculation & Post run, same basis as the Investment Interest module. */
|
||||
interestAccrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.intCalcDate || this.asOnDate);
|
||||
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
processInterest(): void {
|
||||
const targets = this.intCalcTargets;
|
||||
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No holdings match the selected filters.');
|
||||
this.toast('No holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
|
||||
certNo: h.certNo,
|
||||
instrumentType: h.instrumentType,
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
|
||||
txnDate: this.intCalcDate || this.asOnDate,
|
||||
amount: this.interestAccrued(h),
|
||||
particulars: `Interest calculated - ${h.scriptName}`
|
||||
}));
|
||||
const toDate = this.intCalcToDate || this.asOnDate;
|
||||
const fromDate = this.intCalcFromDate || toDate;
|
||||
const glCode = this.intCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.interestAccrued(h);
|
||||
h.interestPosted = (h.interestPosted ?? 0) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
instrumentType: h.instrumentType,
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
|
||||
glCode,
|
||||
glName
|
||||
};
|
||||
});
|
||||
this.txns = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.intCalcFromDate = toDate;
|
||||
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||
this.toast(`Interest processed for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
|
||||
this.toast(
|
||||
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Sell / Redeem Entry --------------------
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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, ViewChild, forwardRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `<input type="text" placeholder="DD-MM-YYYY">` used throughout the
|
||||
* Investment module. Keeps the DD-MM-YYYY string every date field/helper in investment-data.ts
|
||||
* already expects (parseDDMMYYYY, addPeriod, etc.), but adds a calendar button that opens the
|
||||
* browser's native date picker and converts the picked value back to DD-MM-YYYY — so users can
|
||||
* either type the date or pick it, without changing any consuming component's data model.
|
||||
*
|
||||
* Usage: <mifosx-date-ddmmyyyy [(ngModel)]="someField"></mifosx-date-ddmmyyyy>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-date-ddmmyyyy',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => DateDdmmyyyyPickerComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
template: `
|
||||
<div class="inv-date-field">
|
||||
<input
|
||||
type="text"
|
||||
class="inv-date-field__text"
|
||||
[ngModel]="value"
|
||||
(ngModelChange)="onTextChange($event)"
|
||||
(blur)="onTouched()"
|
||||
placeholder="DD-MM-YYYY"
|
||||
[disabled]="disabled"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="inv-date-field__btn"
|
||||
tabindex="-1"
|
||||
[disabled]="disabled"
|
||||
(click)="openPicker()"
|
||||
aria-label="Pick a date"
|
||||
title="Pick a date"
|
||||
>
|
||||
📅
|
||||
</button>
|
||||
<input
|
||||
#native
|
||||
type="date"
|
||||
class="inv-date-field__native"
|
||||
tabindex="-1"
|
||||
[ngModel]="isoValue"
|
||||
(ngModelChange)="onPickerChange($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
/* Angular view encapsulation stops the host page's .inv-form-field styles from reaching
|
||||
* inside this component's own template, so the look of the surrounding form fields
|
||||
* (height, border, radius, font) is deliberately re-declared here instead of inherited. */
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-date-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-date-field__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
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-date-field__btn {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.inv-date-field__btn:hover:not(:disabled) {
|
||||
background: #f2f4f7;
|
||||
}
|
||||
.inv-date-field__btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.inv-date-field__native {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class DateDdmmyyyyPickerComponent implements ControlValueAccessor {
|
||||
@ViewChild('native') private nativeInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
|
||||
private onChangeFn: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
/** DD-MM-YYYY -> yyyy-MM-dd, the format <input type="date"> requires. */
|
||||
get isoValue(): string {
|
||||
const parts = (this.value || '').split('-');
|
||||
if (parts.length !== 3) {
|
||||
return '';
|
||||
}
|
||||
const [
|
||||
dd,
|
||||
mm,
|
||||
yyyy
|
||||
] = parts;
|
||||
if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy)) {
|
||||
return '';
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
writeValue(v: string): void {
|
||||
this.value = v ?? '';
|
||||
}
|
||||
|
||||
registerOnChange(fn: (v: string) => void): void {
|
||||
this.onChangeFn = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
onTextChange(v: string): void {
|
||||
this.value = v;
|
||||
this.onChangeFn(v);
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd (from the native picker) -> DD-MM-YYYY, the format the rest of the app expects. */
|
||||
onPickerChange(iso: string): void {
|
||||
const parts = (iso || '').split('-');
|
||||
if (parts.length !== 3) {
|
||||
return;
|
||||
}
|
||||
const [
|
||||
yyyy,
|
||||
mm,
|
||||
dd
|
||||
] = parts;
|
||||
const ddmmyyyy = `${dd}-${mm}-${yyyy}`;
|
||||
this.value = ddmmyyyy;
|
||||
this.onChangeFn(ddmmyyyy);
|
||||
this.onTouched();
|
||||
}
|
||||
|
||||
openPicker(): void {
|
||||
const el = this.nativeInput?.nativeElement;
|
||||
if (!el || this.disabled) {
|
||||
return;
|
||||
}
|
||||
const showPicker = (el as HTMLInputElement & { showPicker?: () => void }).showPicker;
|
||||
if (typeof showPicker === 'function') {
|
||||
try {
|
||||
showPicker.call(el);
|
||||
return;
|
||||
} catch {
|
||||
// Some browsers throw if not called from a "user gesture" context they recognise —
|
||||
// fall back to a plain click(), which still opens the native picker.
|
||||
}
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
@@ -179,8 +179,8 @@
|
||||
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Certificate Date</span><input type="text" [(ngModel)]="purchaseForm.certDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Sub Type</span>
|
||||
<select [(ngModel)]="purchaseForm.subType">
|
||||
@@ -198,12 +198,12 @@
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transaction Date</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.transactionDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Deposit Date *</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.depositDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
|
||||
/></label>
|
||||
@@ -217,8 +217,8 @@
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Date</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.interestDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
|
||||
/></label>
|
||||
@@ -235,8 +235,8 @@
|
||||
} @else {
|
||||
<label class="inv-form-field"
|
||||
><span>Maturity Date (manual) *</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.manualMaturityDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
|
||||
@@ -327,11 +327,25 @@
|
||||
<span>Certificate No *</span>
|
||||
<select [(ngModel)]="interestCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
@for (h of interestEligibleHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="interestGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (interestHolding; as h) {
|
||||
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
|
||||
<label class="inv-form-field"
|
||||
@@ -341,7 +355,7 @@
|
||||
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Accrued Interest (as on {{ asOnDate }})</span
|
||||
><span>Interest for Period</span
|
||||
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
@@ -376,8 +390,8 @@
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Sell / Premature Date *</span><input type="text" [(ngModel)]="sellDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
@@ -454,6 +468,7 @@
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -467,6 +482,7 @@
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
FD_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
@@ -17,10 +18,14 @@ import {
|
||||
FD_TYPE_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
LIEN_REASON_MASTER,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InterestFreq,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
canAccrueInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
@@ -83,7 +88,8 @@ interface FdPurchaseForm {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class FdInvestmentComponent {
|
||||
@@ -98,6 +104,7 @@ export class FdInvestmentComponent {
|
||||
readonly fdTypeOptions = FD_TYPE_OPTIONS;
|
||||
readonly intTypeOptions = INT_TYPE_OPTIONS;
|
||||
readonly lienReasonOptions = LIEN_REASON_MASTER;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
readonly branchOptions = [
|
||||
'HEAD OFFICE',
|
||||
'BRANCH 1',
|
||||
@@ -114,6 +121,9 @@ export class FdInvestmentComponent {
|
||||
searchCertNo = '';
|
||||
|
||||
interestCertNo = '';
|
||||
interestFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
interestToDate = this.asOnDate;
|
||||
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
|
||||
sellCertNo = '';
|
||||
sellDate = this.asOnDate;
|
||||
sellRemark = '';
|
||||
@@ -313,12 +323,24 @@ export class FdInvestmentComponent {
|
||||
}
|
||||
|
||||
// -------------------- Interest --------------------
|
||||
/** Certificates eligible for accrual-basis posting — same rule as the Investment Interest
|
||||
* module: a Non-Performing holding never appears here, only through Interest Receive. */
|
||||
get interestEligibleHoldings(): InvestmentHolding[] {
|
||||
return this.activeHoldings.filter((h) => canAccrueInterest(h));
|
||||
}
|
||||
|
||||
get interestHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.interestCertNo);
|
||||
}
|
||||
|
||||
/** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based
|
||||
* Interest Calculation & Post run, same basis as the Investment Interest module. */
|
||||
interestAccrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.asOnDate);
|
||||
return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
|
||||
}
|
||||
|
||||
postInterest(): void {
|
||||
@@ -328,19 +350,34 @@ export class FdInvestmentComponent {
|
||||
return;
|
||||
}
|
||||
const amt = this.interestAccrued(h);
|
||||
if (amt <= 0) {
|
||||
this.toast('No interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.interestToDate || this.asOnDate;
|
||||
const fromDate = this.interestFromDate || toDate;
|
||||
const glCode = this.interestGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
h.interestPosted = (h.interestPosted ?? 0) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
this.interestFromDate = toDate;
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Interest posted - ${h.party} (${h.interestFreq})`
|
||||
particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
|
||||
glCode,
|
||||
glName
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted for Certificate ${h.certNo}.`);
|
||||
this.toast(
|
||||
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
|
||||
);
|
||||
this.interestCertNo = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,16 @@
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
|
||||
<p class="pg-sub">
|
||||
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
|
||||
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
|
||||
the room that framework leaves.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instrument Type</th>
|
||||
<th>Target Basis</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num">Actual %</th>
|
||||
<th class="num">Actual Value</th>
|
||||
@@ -100,6 +106,11 @@
|
||||
@for (a of allocationVsTarget; track a.type) {
|
||||
<tr>
|
||||
<td>{{ a.label }}</td>
|
||||
<td>
|
||||
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
|
||||
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ a.targetPct }}%</td>
|
||||
<td class="num">{{ a.actualPct }}%</td>
|
||||
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
ALL_HOLDINGS,
|
||||
ALLOCATION_TARGET_IS_RBI_LINKED,
|
||||
INSTRUMENT_LABELS,
|
||||
InstrumentType,
|
||||
InvestmentHolding,
|
||||
@@ -39,6 +40,9 @@ interface AllocationRow {
|
||||
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
|
||||
@@ -114,7 +118,8 @@ export class InvestmentDashboardComponent {
|
||||
actualPct: +actualPct.toFixed(1),
|
||||
actualValue: value,
|
||||
variancePct,
|
||||
statusLabel
|
||||
statusLabel,
|
||||
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -195,6 +200,7 @@ export class InvestmentDashboardComponent {
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Instrument Type',
|
||||
'Target Basis',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Actual Value',
|
||||
@@ -203,6 +209,7 @@ export class InvestmentDashboardComponent {
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
20,
|
||||
12,
|
||||
12,
|
||||
18,
|
||||
@@ -211,6 +218,7 @@ export class InvestmentDashboardComponent {
|
||||
],
|
||||
rows: this.allocationVsTarget.map((a) => [
|
||||
a.label,
|
||||
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
|
||||
a.targetPct,
|
||||
a.actualPct,
|
||||
a.actualValue,
|
||||
|
||||
@@ -53,16 +53,45 @@ export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string>
|
||||
LOSS: 'Loss (NPI)'
|
||||
};
|
||||
|
||||
/** Board-approved portfolio-policy target — % of the total investment corpus each instrument
|
||||
* type should hold. Compared against actual holdings on the Dashboard so a treasury desk can
|
||||
* see at a glance where the book has drifted from policy. */
|
||||
/**
|
||||
* Portfolio-policy target — % of the total investment corpus each instrument type should hold.
|
||||
* Compared against actual holdings on the Dashboard so a treasury desk can see at a glance where
|
||||
* the book has drifted from policy. Four of these six trace back to real RBI rules for Urban
|
||||
* Co-operative Banks (RBI Master Direction/Circular — Investment Portfolio of Primary (Urban)
|
||||
* Co-operative Banks):
|
||||
* • SLR: UCBs must hold 18% of NDTL in approved securities — predominantly Central/State Govt
|
||||
* securities (and SLR-eligible State Development Loan bonds). A well-run treasury holds a
|
||||
* safety buffer above the bare floor, so Govt Security dominates the book far beyond the 18%
|
||||
* minimum — it isn't itself an upper target, just the reason this bucket is the largest.
|
||||
* • Non-SLR ceiling: a UCB's Non-SLR investments must not exceed 10% of its total deposits as
|
||||
* on 31 March of the previous year — a hard regulatory cap, not a board choice.
|
||||
* • Mutual Funds: RBI permits UCBs to invest only in debt-oriented schemes of SEBI-registered
|
||||
* mutual funds, in strictly limited amounts — equity/balanced schemes are not permitted.
|
||||
* • Shares: RBI does not permit UCBs to invest in equity shares of companies as a general
|
||||
* investment class; any "Share" holding is essentially the (small, largely static) shares a
|
||||
* UCB holds in affiliated co-operative institutions, not a discretionary trading book.
|
||||
* Fixed Deposit (treasury placements with other banks) and Bond (a mix of SLR-eligible SDL and
|
||||
* Non-SLR PSU/FI bonds) have no RBI-mandated percentage of their own — their split here is a
|
||||
* board-approved policy choice within the room RBI's SLR/Non-SLR framework leaves.
|
||||
*/
|
||||
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
|
||||
GOVT_SECURITY: 55,
|
||||
BOND: 18,
|
||||
FD: 15,
|
||||
GOVT_SECURITY: 35,
|
||||
NON_SLR: 10,
|
||||
BOND: 20,
|
||||
MUTUAL_FUND: 12,
|
||||
SHARE: 8
|
||||
NON_SLR: 7,
|
||||
MUTUAL_FUND: 3,
|
||||
SHARE: 2
|
||||
};
|
||||
|
||||
/** True for the instrument types whose target traces to an actual RBI rule (SLR floor / Non-SLR
|
||||
* ceiling) rather than a board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment. */
|
||||
export const ALLOCATION_TARGET_IS_RBI_LINKED: Record<InstrumentType, boolean> = {
|
||||
GOVT_SECURITY: true,
|
||||
BOND: false,
|
||||
FD: false,
|
||||
NON_SLR: true,
|
||||
MUTUAL_FUND: true,
|
||||
SHARE: true
|
||||
};
|
||||
|
||||
export interface InvestmentHolding {
|
||||
@@ -90,6 +119,10 @@ export interface InvestmentHolding {
|
||||
interestPosted?: number;
|
||||
/** Cumulative interest actually received in cash to date. */
|
||||
interestReceived?: number;
|
||||
/** DD-MM-YYYY through which interest has already been calculated & posted. accruedInterestBetween()
|
||||
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
|
||||
* or repeated period always yields 0 instead of double-posting the same interest. */
|
||||
interestPostedTillDate?: string;
|
||||
}
|
||||
|
||||
export interface InvestmentTxn {
|
||||
@@ -100,6 +133,10 @@ export interface InvestmentTxn {
|
||||
txnDate: string;
|
||||
amount: number;
|
||||
particulars: string;
|
||||
/** GL account this entry was posted/received against — the Interest Income GL for a Post,
|
||||
* the Cash/Bank GL for a Receive. Absent on older/seeded entries that predate GL selection. */
|
||||
glCode?: string;
|
||||
glName?: string;
|
||||
}
|
||||
|
||||
// -------------------- Date / interest helpers --------------------
|
||||
@@ -175,15 +212,6 @@ export function canAccrueInterest(h: InvestmentHolding): boolean {
|
||||
return !isNpi(h);
|
||||
}
|
||||
|
||||
/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting
|
||||
* an accrual on a non-performing investment would overstate income. */
|
||||
export function interestToPost(h: InvestmentHolding, asOnStr: string): number {
|
||||
if (!canAccrueInterest(h)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0));
|
||||
}
|
||||
|
||||
/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income)
|
||||
* but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever
|
||||
* posted as a receivable on one. */
|
||||
@@ -203,6 +231,23 @@ export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number
|
||||
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
|
||||
}
|
||||
|
||||
/** Interest accrued strictly within [fromStr, toStr], clipped to the holding's own
|
||||
* purchase→maturity window — the basis for a period-based Interest Calculation & Post run
|
||||
* (as opposed to accruedInterest(), which is always measured from purchase date). Returns 0
|
||||
* for a Non-Performing holding, same accrual-basis rule as accruedInterest(). */
|
||||
export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, toStr: string): number {
|
||||
if (!canAccrueInterest(h)) {
|
||||
return 0;
|
||||
}
|
||||
const purchase = parseDDMMYYYY(h.purchaseDate).getTime();
|
||||
const maturity = parseDDMMYYYY(h.maturityDate).getTime();
|
||||
const postedTill = h.interestPostedTillDate ? parseDDMMYYYY(h.interestPostedTillDate).getTime() : purchase;
|
||||
const from = Math.max(parseDDMMYYYY(fromStr).getTime(), purchase, postedTill);
|
||||
const to = Math.min(parseDDMMYYYY(toStr).getTime(), maturity);
|
||||
const days = Math.max(0, (to - from) / (24 * 3600 * 1000));
|
||||
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
|
||||
}
|
||||
|
||||
export function formatDDMMYYYY(d: Date): string {
|
||||
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||
}
|
||||
@@ -388,6 +433,68 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: '15628',
|
||||
instrumentType: 'FD',
|
||||
scriptCode: 'FD-CUM',
|
||||
scriptName: 'Cumulative Fixed Deposit',
|
||||
party: 'Zilla Sahakari Bank Ltd',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 1800000,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: '20-08-2025',
|
||||
maturityDate: '20-08-2026',
|
||||
interestRate: 6.8,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '320020',
|
||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: '15629',
|
||||
instrumentType: 'FD',
|
||||
scriptCode: 'FD-MIP',
|
||||
scriptName: 'Monthly Interest Payout FD',
|
||||
party: 'Vidarbha Cooperative Bank',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 2200000,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: '25-09-2025',
|
||||
maturityDate: '25-09-2026',
|
||||
interestRate: 7.15,
|
||||
interestFreq: 'MONTHLY',
|
||||
glCode: '320020',
|
||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE',
|
||||
interestPosted: 143220,
|
||||
interestReceived: 130800
|
||||
},
|
||||
{
|
||||
certNo: '15630',
|
||||
instrumentType: 'FD',
|
||||
scriptCode: 'FD-CUM',
|
||||
scriptName: 'Cumulative Fixed Deposit',
|
||||
party: 'Konkan Cooperative Bank Ltd',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 900000,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: '28-10-2024',
|
||||
maturityDate: '28-10-2026',
|
||||
interestRate: 6.75,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '320020',
|
||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -515,6 +622,46 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-002',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'GSEC-2007',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
scriptCode: 'TBILL-91D',
|
||||
scriptName: '91 Days Treasury Bill',
|
||||
party: 'RBI - SGL',
|
||||
subType: 'SLR',
|
||||
faceValue: 2000000,
|
||||
purchaseRate: 98.2,
|
||||
units: 20000,
|
||||
purchaseDate: '15-05-2026',
|
||||
maturityDate: '13-08-2026',
|
||||
interestRate: 6.2,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '310020',
|
||||
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-003',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'GSEC-2008',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
scriptCode: 'GJ-SDL-2026',
|
||||
scriptName: '7.85% Gujarat SDL 2026',
|
||||
party: 'RBI - SGL',
|
||||
subType: 'SLR',
|
||||
faceValue: 1800000,
|
||||
purchaseRate: 100.2,
|
||||
units: 18000,
|
||||
purchaseDate: '18-09-2025',
|
||||
maturityDate: '18-09-2026',
|
||||
interestRate: 7.85,
|
||||
interestFreq: 'HALF_YEARLY',
|
||||
glCode: '310020',
|
||||
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-001',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -620,6 +767,46 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
|
||||
status: 'ACTIVE',
|
||||
interestPosted: 89400,
|
||||
interestReceived: 60000
|
||||
},
|
||||
{
|
||||
certNo: 'NSLR-3006',
|
||||
instrumentType: 'NON_SLR',
|
||||
scriptCode: 'IRFC-7.55-2026',
|
||||
scriptName: 'IRFC Bond 7.55% 2026',
|
||||
party: 'Indian Railway Finance Corp',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 1600000,
|
||||
purchaseRate: 100,
|
||||
units: 16000,
|
||||
purchaseDate: '25-08-2025',
|
||||
maturityDate: '25-08-2026',
|
||||
interestRate: 7.55,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310030',
|
||||
glName: 'INVESTMENT IN NON SLR SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-004',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'NSLR-3007',
|
||||
instrumentType: 'NON_SLR',
|
||||
scriptCode: 'NHB-7.3-2026',
|
||||
scriptName: 'NHB Bond 7.30% 2026',
|
||||
party: 'National Housing Bank',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 1000000,
|
||||
purchaseRate: 100,
|
||||
units: 10000,
|
||||
purchaseDate: '10-10-2025',
|
||||
maturityDate: '10-10-2026',
|
||||
interestRate: 7.3,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310030',
|
||||
glName: 'INVESTMENT IN NON SLR SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-003',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -726,6 +913,46 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-005',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'BOND-4006',
|
||||
instrumentType: 'BOND',
|
||||
scriptCode: 'CHOLA-8.35-2026',
|
||||
scriptName: 'Cholamandalam Finance NCD 8.35% 2026',
|
||||
party: 'Cholamandalam Investment & Finance',
|
||||
subType: 'CORPORATE BOND',
|
||||
faceValue: 1400000,
|
||||
purchaseRate: 100,
|
||||
units: 14000,
|
||||
purchaseDate: '15-09-2024',
|
||||
maturityDate: '15-09-2026',
|
||||
interestRate: 8.35,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310040',
|
||||
glName: 'INVESTMENT IN CORPORATE BONDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-006',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'BOND-4007',
|
||||
instrumentType: 'BOND',
|
||||
scriptCode: 'PEL-8.4-2026',
|
||||
scriptName: 'Piramal Enterprises NCD 8.40% 2026',
|
||||
party: 'Piramal Enterprises Ltd',
|
||||
subType: 'CORPORATE BOND',
|
||||
faceValue: 900000,
|
||||
purchaseRate: 100,
|
||||
units: 9000,
|
||||
purchaseDate: '28-10-2025',
|
||||
maturityDate: '28-10-2026',
|
||||
interestRate: 8.4,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310040',
|
||||
glName: 'INVESTMENT IN CORPORATE BONDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-005',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -829,6 +1056,46 @@ export const MUTUAL_FUND_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'MF-5006',
|
||||
instrumentType: 'MUTUAL_FUND',
|
||||
scriptCode: 'SBI-BPSU',
|
||||
scriptName: 'SBI Banking & PSU Fund',
|
||||
party: 'SBI Mutual Fund',
|
||||
subType: 'DEBT - BANKING & PSU',
|
||||
faceValue: 700000,
|
||||
purchaseRate: 18.6,
|
||||
units: 37634.4,
|
||||
purchaseDate: '10-09-2025',
|
||||
maturityDate: '10-09-2026',
|
||||
interestRate: 7.1,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '310050',
|
||||
glName: 'INVESTMENT IN MUTUAL FUNDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'MF-5007',
|
||||
instrumentType: 'MUTUAL_FUND',
|
||||
scriptCode: 'ICICI-CB',
|
||||
scriptName: 'ICICI Pru Corporate Bond Fund',
|
||||
party: 'ICICI Prudential Mutual Fund',
|
||||
subType: 'DEBT - CORPORATE BOND',
|
||||
faceValue: 1100000,
|
||||
purchaseRate: 24.8,
|
||||
units: 44354.84,
|
||||
purchaseDate: '14-07-2026',
|
||||
maturityDate: '14-07-2027',
|
||||
interestRate: 7.05,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '310050',
|
||||
glName: 'INVESTMENT IN MUTUAL FUNDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -932,6 +1199,46 @@ export const SHARE_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'SH-6006',
|
||||
instrumentType: 'SHARE',
|
||||
scriptCode: 'ICICIBANK',
|
||||
scriptName: 'ICICI Bank Ltd',
|
||||
party: 'NSE/BSE',
|
||||
subType: 'EQUITY',
|
||||
faceValue: 420000,
|
||||
purchaseRate: 1050,
|
||||
units: 400,
|
||||
purchaseDate: '05-03-2026',
|
||||
maturityDate: '05-03-2036',
|
||||
interestRate: 2.0,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310060',
|
||||
glName: 'INVESTMENT IN SHARES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-007',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'SH-6007',
|
||||
instrumentType: 'SHARE',
|
||||
scriptCode: 'PDCC',
|
||||
scriptName: 'Pune District Central Coop Bank Shares',
|
||||
party: 'Pune District Central Cooperative Bank',
|
||||
subType: 'CO-OP SHARE',
|
||||
faceValue: 60000,
|
||||
purchaseRate: 50,
|
||||
units: 1200,
|
||||
purchaseDate: '18-07-2025',
|
||||
maturityDate: '18-07-2035',
|
||||
interestRate: 5.0,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310060',
|
||||
glName: 'INVESTMENT IN SHARES',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1060,6 +1367,43 @@ export const ALL_TXNS: InvestmentTxn[] = [
|
||||
|
||||
// -------------------- Masters --------------------
|
||||
|
||||
export interface InvestmentGlOption {
|
||||
glCode: string;
|
||||
glName: string;
|
||||
}
|
||||
|
||||
/** Interest Income (Cr) GL per instrument type — the GL an Interest Calculation & Post run
|
||||
* credits. Selectable on the FD/Govt./Bond Interest Calculation screens instead of being
|
||||
* hard-wired, so a period's posting can be routed to a different income head when needed. */
|
||||
export const INTEREST_INCOME_GL_MASTER: InvestmentGlOption[] = [
|
||||
{ glCode: '410010', glName: 'INTEREST ON GOVT. SECURITIES' },
|
||||
{ glCode: '410020', glName: 'INTEREST ON NON SLR SECURITIES' },
|
||||
{ glCode: '410030', glName: 'INTEREST ON CORPORATE BONDS' },
|
||||
{ glCode: '410040', glName: 'INTEREST ON FIXED DEPOSITS' },
|
||||
{ glCode: '410050', glName: 'INCOME FROM MUTUAL FUNDS' },
|
||||
{ glCode: '410060', glName: 'DIVIDEND ON SHARES' }
|
||||
];
|
||||
|
||||
/** Default Interest Income GL suggested per instrument type — pre-fills the selector above,
|
||||
* which the user can still override before posting. */
|
||||
export const INTEREST_INCOME_GL_BY_TYPE: Record<InstrumentType, string> = {
|
||||
GOVT_SECURITY: '410010',
|
||||
NON_SLR: '410020',
|
||||
BOND: '410030',
|
||||
FD: '410040',
|
||||
MUTUAL_FUND: '410050',
|
||||
SHARE: '410060'
|
||||
};
|
||||
|
||||
/** Cash/Bank (Dr) GL credited when an Interest Receive entry is booked — the account the cash
|
||||
* actually lands in. Selectable on the Interest Receive screen. */
|
||||
export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
|
||||
{ glCode: '100010', glName: 'CASH IN HAND' },
|
||||
{ glCode: '110010', glName: 'RBI CURRENT ACCOUNT' },
|
||||
{ glCode: '110020', glName: 'SBI CURRENT ACCOUNT' },
|
||||
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
|
||||
];
|
||||
|
||||
export interface ScriptMaster {
|
||||
scriptCode: string;
|
||||
scriptName: string;
|
||||
|
||||
@@ -184,8 +184,11 @@
|
||||
<div class="pos-section-label">Investment - FD Interest Calculation & Post</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No</span>
|
||||
<select [(ngModel)]="fdCalcCertNo">
|
||||
@@ -198,6 +201,14 @@
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="fdCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Total Interest to Post</span
|
||||
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
|
||||
@@ -257,11 +268,14 @@
|
||||
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation & Post</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Type</span>
|
||||
<select [(ngModel)]="bondCalcType">
|
||||
<select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
|
||||
<option value="">-- All Types --</option>
|
||||
<option value="GOVT_SECURITY">GOVT SECURITY</option>
|
||||
<option value="NON_SLR">NON SLR</option>
|
||||
@@ -281,6 +295,14 @@
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="bondCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Total Interest to Post</span
|
||||
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
|
||||
@@ -342,6 +364,16 @@
|
||||
@if (activeTab === 'receive') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Receive Into (Cash/Bank GL)</span>
|
||||
<select [(ngModel)]="receiveGlCode">
|
||||
@for (g of cashBankGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -396,6 +428,7 @@
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Credited</th>
|
||||
<th class="num col-in">Amount Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -408,17 +441,18 @@
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No interest received yet.</td>
|
||||
<td colspan="6" class="pg-empty">No interest received yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total Received</td>
|
||||
<td colspan="5">Total Received</td>
|
||||
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -438,6 +472,7 @@
|
||||
<th>Cert No</th>
|
||||
<th>Instrument</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num col-in">Interest Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -451,17 +486,18 @@
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ instrumentLabel(t.instrumentType) }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No bond interest entries recorded yet.</td>
|
||||
<td colspan="7" class="pg-empty">No bond interest entries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="5">Total</td>
|
||||
<td colspan="6">Total</td>
|
||||
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -480,6 +516,7 @@
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num col-in">Interest Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -492,17 +529,18 @@
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No FD interest entries recorded yet.</td>
|
||||
<td colspan="6" class="pg-empty">No FD interest entries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total</td>
|
||||
<td colspan="5">Total</td>
|
||||
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
FD_HOLDINGS,
|
||||
GOVT_SECURITY_HOLDINGS,
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
BOND_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
SCRIPT_MASTER,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
CASH_BANK_GL_MASTER,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InstrumentType,
|
||||
@@ -23,12 +27,13 @@ import {
|
||||
INSTRUMENT_LABELS,
|
||||
PROVISION_STATUS_LABELS,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
addPeriod,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
todayDDMMYYYY,
|
||||
isNpi,
|
||||
canAccrueInterest,
|
||||
interestToPost,
|
||||
interestReceivableBalance,
|
||||
suspenseInterest,
|
||||
provisionRequiredInv
|
||||
@@ -67,7 +72,8 @@ type InterestTab =
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentInterestComponent {
|
||||
@@ -83,6 +89,8 @@ export class InvestmentInterestComponent {
|
||||
txns: InvestmentTxn[] = [...ALL_TXNS];
|
||||
|
||||
readonly scriptOptions = SCRIPT_MASTER;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
readonly cashBankGlOptions = CASH_BANK_GL_MASTER;
|
||||
|
||||
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
|
||||
receiveDrafts: Record<string, number | null> = {};
|
||||
@@ -92,17 +100,24 @@ export class InvestmentInterestComponent {
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// 01 FD Interest Calculation
|
||||
fdCalcDate = this.asOnDate;
|
||||
// 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
|
||||
fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
fdCalcToDate = this.asOnDate;
|
||||
fdCalcCertNo = '';
|
||||
fdCalcAll = false;
|
||||
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
|
||||
|
||||
// 02 Govt./Bond Int. Calculation
|
||||
bondCalcDate = this.asOnDate;
|
||||
// 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
|
||||
bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
bondCalcToDate = this.asOnDate;
|
||||
bondCalcType: InstrumentType | '' = '';
|
||||
bondCalcScript = '';
|
||||
bondCalcDealNo = '';
|
||||
bondCalcAll = false;
|
||||
bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
|
||||
|
||||
// Interest Receive — Cash/Bank GL credited for the day's collection voucher
|
||||
receiveGlCode = CASH_BANK_GL_MASTER[0].glCode;
|
||||
|
||||
// Register filters
|
||||
registerFromDate = '';
|
||||
@@ -184,38 +199,56 @@ export class InvestmentInterestComponent {
|
||||
}
|
||||
|
||||
fdInterest(h: InvestmentHolding): number {
|
||||
return interestToPost(h, this.fdCalcDate || this.asOnDate);
|
||||
return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
get fdCalcTotal(): number {
|
||||
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return (
|
||||
this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ??
|
||||
this.cashBankGlOptions.find((g) => g.glCode === glCode)?.glName ??
|
||||
glCode
|
||||
);
|
||||
}
|
||||
|
||||
processFdInterest(): void {
|
||||
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No FD holdings have interest due to be posted.');
|
||||
this.toast('No FD holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.fdCalcToDate || this.asOnDate;
|
||||
const fromDate = this.fdCalcFromDate || toDate;
|
||||
const glCode = this.fdCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.fdInterest(h);
|
||||
h.interestPosted = this.posted(h) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: this.fdCalcDate || this.asOnDate,
|
||||
voucherNo: `INV/FD-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `FD interest posted (accrual) - ${h.party}`
|
||||
particulars: `FD interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party}`,
|
||||
glCode,
|
||||
glName
|
||||
};
|
||||
});
|
||||
this.fdCalcFromDate = toDate;
|
||||
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||
this.txns = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`);
|
||||
this.toast(
|
||||
`FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- 02 Govt./Bond Int. Calculation --------------------
|
||||
@@ -239,30 +272,44 @@ export class InvestmentInterestComponent {
|
||||
}
|
||||
|
||||
bondInterest(h: InvestmentHolding): number {
|
||||
return interestToPost(h, this.bondCalcDate || this.asOnDate);
|
||||
return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
get bondCalcTotal(): number {
|
||||
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
|
||||
}
|
||||
|
||||
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
|
||||
onBondCalcTypeChange(): void {
|
||||
if (this.bondCalcType) {
|
||||
this.bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.bondCalcType];
|
||||
}
|
||||
}
|
||||
|
||||
processBondInterest(): void {
|
||||
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No Govt/Bond holdings have interest due to be posted.');
|
||||
this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.bondCalcToDate || this.asOnDate;
|
||||
const fromDate = this.bondCalcFromDate || toDate;
|
||||
const glCode = this.bondCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.bondInterest(h);
|
||||
h.interestPosted = this.posted(h) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
instrumentType: h.instrumentType,
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: this.bondCalcDate || this.asOnDate,
|
||||
voucherNo: `INV/BOND-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Coupon/interest posted (accrual) - ${h.scriptName}`
|
||||
particulars: `Coupon/interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
|
||||
glCode,
|
||||
glName
|
||||
};
|
||||
});
|
||||
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||
@@ -270,7 +317,10 @@ export class InvestmentInterestComponent {
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
|
||||
this.bondCalcFromDate = toDate;
|
||||
this.toast(
|
||||
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Interest Receivable (all interest-bearing types) --------------------
|
||||
@@ -331,6 +381,8 @@ export class InvestmentInterestComponent {
|
||||
h.interestReceived = this.received(h) + amt;
|
||||
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
|
||||
}
|
||||
const glCode = this.receiveGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
@@ -339,11 +391,13 @@ export class InvestmentInterestComponent {
|
||||
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: amt,
|
||||
particulars
|
||||
particulars: `${particulars} (Dr. ${glCode} - ${glName})`,
|
||||
glCode,
|
||||
glName
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`);
|
||||
this.toast(`₹${amt.toLocaleString('en-IN')} interest received into ${glCode} - ${glName} for ${h.certNo}.`);
|
||||
delete this.receiveDrafts[h.certNo];
|
||||
}
|
||||
|
||||
@@ -400,7 +454,9 @@ export class InvestmentInterestComponent {
|
||||
t.certNo,
|
||||
t.instrumentType,
|
||||
t.particulars,
|
||||
t.amount
|
||||
t.amount,
|
||||
t.glCode ?? '',
|
||||
t.glName ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
@@ -412,7 +468,9 @@ export class InvestmentInterestComponent {
|
||||
'Cert No',
|
||||
'Type',
|
||||
'Particulars',
|
||||
'Amount'
|
||||
'Amount',
|
||||
'GL Code',
|
||||
'GL Name'
|
||||
];
|
||||
const txnWidths = [
|
||||
14,
|
||||
@@ -420,7 +478,9 @@ export class InvestmentInterestComponent {
|
||||
14,
|
||||
14,
|
||||
42,
|
||||
16
|
||||
16,
|
||||
12,
|
||||
28
|
||||
];
|
||||
const one = (
|
||||
sheetName: string,
|
||||
@@ -501,72 +561,78 @@ export class InvestmentInterestComponent {
|
||||
return sheets;
|
||||
}
|
||||
case 'fdCalc':
|
||||
return one(
|
||||
'FD Interest Calc',
|
||||
'FD Interest Calculation & Post',
|
||||
[
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
this.fdCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.fdInterest(h)
|
||||
])
|
||||
);
|
||||
return [
|
||||
{
|
||||
sheetName: 'FD Interest Calc',
|
||||
title: 'FD Interest Calculation & Post',
|
||||
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: this.fdCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.fdInterest(h)
|
||||
])
|
||||
}
|
||||
];
|
||||
case 'bondCalc':
|
||||
return one(
|
||||
'Bond Interest Calc',
|
||||
'Govt./Bond Interest Calculation & Post',
|
||||
[
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
this.bondCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.bondInterest(h)
|
||||
])
|
||||
);
|
||||
return [
|
||||
{
|
||||
sheetName: 'Bond Interest Calc',
|
||||
title: 'Govt./Bond Interest Calculation & Post',
|
||||
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: this.bondCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.bondInterest(h)
|
||||
])
|
||||
}
|
||||
];
|
||||
case 'receive': {
|
||||
const sheets: ExportSheetSpec[] = [
|
||||
{
|
||||
sheetName: 'Receive Targets',
|
||||
title: 'Interest Receive — Outstanding for Collection',
|
||||
subtitle,
|
||||
subtitle: `${subtitle} · Receive into ${this.receiveGlCode} - ${this.glName(this.receiveGlCode)}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script / Party',
|
||||
|
||||
@@ -267,8 +267,8 @@
|
||||
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Empanelled On</span><input type="text" [(ngModel)]="newBroker.empanelledOn" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
|
||||
@@ -349,8 +349,8 @@
|
||||
<div class="pos-section-label">Add Daily Script Rate</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Rate Date *</span><input type="text" [(ngModel)]="newDailyRate.rateDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Script Code *</span>
|
||||
<select [(ngModel)]="newDailyRate.scriptCode">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
SCRIPT_MASTER,
|
||||
BROKER_MASTER,
|
||||
@@ -60,7 +61,8 @@ type MasterTab =
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentMastersComponent {
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Purchase Date *</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.purchaseDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
|
||||
/></label>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
@@ -41,7 +42,8 @@ interface SharePurchaseForm {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class ShareInvestmentComponent {
|
||||
|
||||
@@ -1470,16 +1470,30 @@ export function branchTargetAmount(branchCode: string): number {
|
||||
return BRANCH_DISBURSEMENT_TARGET[branchCode] || 0;
|
||||
}
|
||||
|
||||
/** Target product mix (% of total disbursement) — weighted towards secured Housing/Vehicle
|
||||
* lending plus the priority-sector KCC/Cash Credit book, adding up to 100%. */
|
||||
/**
|
||||
* Target product mix (% of total disbursement). Two of these are genuinely RBI-mandated
|
||||
* Priority Sector Lending sub-targets (RBI Master Direction — Priority Sector Lending: Targets
|
||||
* and Classification, FIDD.CO.Plan.BC.13/04.09.01/2020-21, as extended to UCBs) —
|
||||
* • Agriculture: 18% of Adjusted Net Bank Credit (ANBC) — mapped here to Kisan Credit Card.
|
||||
* • Micro Enterprises: 7.5% of ANBC — mapped here to Cash Credit (MSME).
|
||||
* RBI sets these as a % of the BANK'S WHOLE loan book (ANBC), not of any one product line's own
|
||||
* disbursement — shown here against this module's own mix as the closest available proxy, since
|
||||
* this demo has no bank-wide ANBC figure to check against. The remaining five products (Housing,
|
||||
* Vehicle, Gold, Personal, Education) have no RBI-mandated minimum share of their own — Housing
|
||||
* and Education are Priority-Sector-*eligible* within RBI's loan-amount ceilings but aren't
|
||||
* separately targeted, and Vehicle/Gold/Personal aren't Priority Sector categories at all — so
|
||||
* their split below is an internal business-plan assumption, not a regulatory requirement.
|
||||
* (RBI's other PSL sub-targets — Weaker Sections 12%, overall PSL 40% — cut across products by
|
||||
* borrower category rather than by loan type, so they aren't represented as their own row here.)
|
||||
*/
|
||||
export const PRODUCT_TARGET_PCT: Record<string, number> = {
|
||||
PL: 12,
|
||||
HL: 28,
|
||||
HL: 30,
|
||||
VL: 15,
|
||||
GL: 10,
|
||||
CC: 15,
|
||||
KCC: 12,
|
||||
EL: 8
|
||||
GL: 12,
|
||||
CC: 7.5, // RBI Micro Enterprises PSL sub-target
|
||||
KCC: 18, // RBI Agriculture PSL sub-target
|
||||
EL: 5.5
|
||||
};
|
||||
|
||||
/** Target channel mix (% of disbursement) — the bank's push towards BC/Digital origination and
|
||||
|
||||
@@ -483,10 +483,17 @@
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
KCC and Cash Credit (MSME) targets are RBI's actual Priority Sector Lending sub-targets — Agriculture 18% and
|
||||
Micro Enterprises 7.5% of Adjusted Net Bank Credit (shown here against this module's own mix, since RBI sets
|
||||
them against the bank's whole loan book, not one product line). The rest are internal business-plan targets, not
|
||||
RBI-mandated.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Target Basis</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num col-in">Actual %</th>
|
||||
<th class="num">Variance</th>
|
||||
@@ -497,7 +504,12 @@
|
||||
@for (row of productTargetRows; track row.code) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
|
||||
<td>
|
||||
<span class="loan-badge" [class.loan-badge--progress]="row.isRbiMandated">
|
||||
{{ row.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
|
||||
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
|
||||
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||
@@ -533,7 +545,7 @@
|
||||
@for (row of channelTargetRows; track row.code) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
|
||||
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
|
||||
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||
|
||||
@@ -71,6 +71,9 @@ interface MixTargetRow {
|
||||
actualPct: number;
|
||||
variancePct: number;
|
||||
status: TargetStatus;
|
||||
/** True only for the two rows whose target is an actual RBI PSL sub-target (Agriculture 18% /
|
||||
* Micro Enterprises 7.5% of ANBC) rather than an internal business-plan assumption. */
|
||||
isRbiMandated: boolean;
|
||||
}
|
||||
|
||||
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
|
||||
@@ -389,7 +392,8 @@ export class LoanPipelineComponent implements OnInit {
|
||||
targetPct,
|
||||
actualPct,
|
||||
variancePct: actualPct - targetPct,
|
||||
status: targetStatus(actualPct, targetPct, 3)
|
||||
status: targetStatus(actualPct, targetPct, 3),
|
||||
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -413,7 +417,8 @@ export class LoanPipelineComponent implements OnInit {
|
||||
targetPct,
|
||||
actualPct,
|
||||
variancePct: actualPct - targetPct,
|
||||
status: targetStatus(actualPct, targetPct, 3)
|
||||
status: targetStatus(actualPct, targetPct, 3),
|
||||
isRbiMandated: false
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -626,6 +631,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
subtitle,
|
||||
headers: [
|
||||
'Mix',
|
||||
'Target Basis',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Variance (pp)',
|
||||
@@ -633,6 +639,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
18,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
@@ -641,6 +648,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
rows: [
|
||||
...this.productTargetRows.map((r) => [
|
||||
`Product — ${r.label}`,
|
||||
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
|
||||
r.targetPct,
|
||||
Number(r.actualPct.toFixed(1)),
|
||||
Number(r.variancePct.toFixed(1)),
|
||||
@@ -648,6 +656,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
]),
|
||||
...this.channelTargetRows.map((r) => [
|
||||
`Channel — ${r.label}`,
|
||||
'Internal Target',
|
||||
r.targetPct,
|
||||
Number(r.actualPct.toFixed(1)),
|
||||
Number(r.variancePct.toFixed(1)),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user