feat(investment): interest voucher posting UI, home pending alert and day-end reminder

This commit is contained in:
2026-10-14 15:11:43 +05:30
parent 8a6becff86
commit 0404197c1b
7 changed files with 273 additions and 8 deletions
@@ -693,6 +693,39 @@
@if (activeTab === 'interest') {
<div class="pg-card">
<div class="pos-section-label">Interest Entry — coupon and 31 Mar / 30 Sep provision schedule</div>
@if (intDueTasks.length) {
<div class="pos-section-label">🔔 Interest entries due ({{ intDueTasks.length }})</div>
<table class="pg-table">
<thead>
<tr>
<th>Deal No</th>
<th>Script</th>
<th>Type</th>
<th>Int Date</th>
<th class="num">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (t of intDueTasks; track t.purchaseId) {
<tr>
<td>
<span class="pg-chip">{{ t.dealNo }}</span>
</td>
<td>{{ t.scriptCode }} — {{ t.scriptDesc }}</td>
<td>{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }}</td>
<td>{{ t.intDate }}</td>
<td class="num">₹{{ (t.lineType === 'COUPON' ? t.grossInterest : t.amount) | number: '1.2-2' }}</td>
<td>{{ t.overdue ? 'Overdue — post now' : t.postable ? 'Due today' : 'Due on ' + t.intDate }}</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="openDueTask(t)">Open</button>
</td>
</tr>
}
</tbody>
</table>
}
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Type</span>
@@ -775,14 +808,30 @@
</div>
<div class="pos-section-label">
Interest Schedule ({{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}: ₹{{
intDueTotal | number: '1.2-2'
}}
Interest Schedule ({{ intVisibleRows.length }} of {{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}:
₹{{ intDueTotal | number: '1.2-2' }}
</div>
<p class="pg-sub" style="padding: 0 16px">
Days are 30/360. A provision books the interest earned since the last coupon; the coupon then clears what is
already booked. Display only — nothing is posted to GL yet.
already booked. Post each line with Generate Voucher on or after its date, in date order — the JV voucher is
passed as it is raised.
</p>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Coupon received into (Bank)</span>
<select [(ngModel)]="intBankGlCode">
@for (g of settlementBankOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Schedule</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="intShowFullSchedule = !intShowFullSchedule">
{{ intShowFullSchedule ? 'Show up to next year' : 'Show full schedule to maturity' }}
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
@@ -796,10 +845,11 @@
<th class="num">Already Booked</th>
<th class="num col-in">Amount</th>
<th>Accounting</th>
<th>Voucher</th>
</tr>
</thead>
<tbody>
@for (r of sc.rows; track r.intDate) {
@for (r of intVisibleRows; track r.type + r.intDate) {
<tr [style.opacity]="r.due ? 1 : 0.6">
<td>
<span class="pg-chip">{{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }}</span>
@@ -829,10 +879,26 @@
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
}
</td>
<td style="white-space: nowrap">
@if (r.posted) {
<span class="pg-chip" [title]="'GL ' + r.glTransactionId">Posted · {{ r.voucherNo }}</span>
} @else if (r.postable) {
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="intPosting"
(click)="generateVoucher(r)"
>
Generate Voucher
</button>
} @else if (r.dueSoon) {
<span class="pg-chip">🔔 Due {{ r.intDate }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">
<td colspan="11" class="pg-empty">
No interest lines — check the rate, last interest date and maturity date.
</td>
</tr>
@@ -840,8 +906,8 @@
</tbody>
</table>
<p class="pg-sub" style="padding: 8px 16px 12px">
Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }}. Posting to GL will be enabled after
approval.
Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }} · Interest receivable GL:
{{ interestReceivableGl.glCode }}.
</p>
} @else {
<p class="pg-sub" style="padding: 0 16px 12px">
@@ -9,6 +9,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
SCRIPT_MASTER,
@@ -56,6 +57,8 @@ import {
import { exportWorkbook } from '../investment-export';
import {
InvestmentDailyRateDto,
InvestmentInterestDueDto,
InvestmentInterestRowDto,
InvestmentInterestScheduleDto,
InvestmentPurchaseDto,
InvestmentService,
@@ -205,9 +208,14 @@ interface BondSellForm {
})
export class BondInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
private readonly route = inject(ActivatedRoute);
/** Script and party dropdowns offer the masters saved in the backend. */
ngOnInit(): void {
// The home-screen interest alert links here with ?tab=interest.
if (this.route.snapshot.queryParamMap.get('tab') === 'interest') {
this.activeTab = 'interest';
}
this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
});
@@ -222,6 +230,7 @@ export class BondInvestmentComponent implements OnInit {
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.loadPurchases();
this.loadDueInterest();
}
/** GL id for a GL code — the purchase API takes ids, the form works with codes. */
@@ -307,6 +316,13 @@ export class BondInvestmentComponent implements OnInit {
intAsOnDate = this.asOnDate;
intSchedule: InvestmentInterestScheduleDto | null = null;
intLoading = false;
/** Interest lines of all holdings due now or within two days — what Jitendra Sir asked to be reminded of. */
intDueTasks: InvestmentInterestDueDto[] = [];
/** By default the schedule shows only lines up to the end of next year (and anything already posted). */
intShowFullSchedule = false;
/** Bank a coupon is received into; defaults to the purchase's paying bank. */
intBankGlCode = '';
intPosting = false;
readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
// Write-off
@@ -1052,6 +1068,7 @@ export class BondInvestmentComponent implements OnInit {
this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
next: (schedule) => {
this.intSchedule = schedule;
this.intBankGlCode = schedule.bankGlCode;
this.intLoading = false;
},
error: (err) => {
@@ -1062,6 +1079,63 @@ export class BondInvestmentComponent implements OnInit {
});
}
loadDueInterest(): void {
this.investmentService.getDueInterest().subscribe({
next: (tasks) => (this.intDueTasks = tasks),
error: () => (this.intDueTasks = [])
});
}
/** Opens a due task's holding in the schedule below. */
openDueTask(t: InvestmentInterestDueDto): void {
this.intType = '';
this.intScript = '';
this.intPurchaseId = t.purchaseId;
this.retrieveInterestSchedule();
}
/** Lines shown: up to 31 December of next year, plus anything already posted — unless the full schedule is asked for. */
get intVisibleRows(): InvestmentInterestRowDto[] {
const rows = this.intSchedule?.rows ?? [];
if (this.intShowFullSchedule) {
return rows;
}
const upTo = new Date(new Date().getFullYear() + 1, 11, 31);
return rows.filter((r) => r.posted || parseDDMMYYYY(r.intDate) <= upTo);
}
/** Posts one interest line with its JV voucher (passed as it is raised), then refreshes the schedule and tasks. */
generateVoucher(r: InvestmentInterestRowDto): void {
const sc = this.intSchedule;
if (!sc || this.intPosting) {
return;
}
const bankGlAccountId =
r.type === 'COUPON' ? (this.glIdByCode.get(this.intBankGlCode) ?? sc.bankGlAccountId) : undefined;
const body = {
lineType: r.type,
intDate: r.intDate,
...(bankGlAccountId ? { bankGlAccountId } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.intPosting = true;
this.investmentService.postInterest(sc.purchaseId, body).subscribe({
next: (res) => {
this.intPosting = false;
this.toast(
`${r.type === 'COUPON' ? 'Coupon' : 'Provision'} of ${r.intDate} posted — voucher ${res.resourceIdentifier ?? ''}.`
);
this.retrieveInterestSchedule();
this.loadDueInterest();
},
error: (err) => {
this.intPosting = false;
this.toast(apiErrorMessage(err));
}
});
}
/** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number {
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);