feat: senior citizen rate fix, joint ownership cap, FD/RD UI improvements

- Senior citizen interest rate correctly sent in FD/RD create payload
  (isSeniorCitizen, seniorCitizenInterestRate, nominalAnnualInterestRate override)
- Joint account ownership capped at 100%: Add Holder/Partner button disabled
  when 100% reached; dialog enforces remaining share as max across all account types
- FD/RD interest rate chart step: rate calculator with tenure, senior citizen toggle,
  maturity preview
- FD/RD preview step improvements
- Base FD interest rate chart component added under fixed-deposit-products
- Base recurring deposit interest rate chart component removed (replaced)
- Savings account details step: joint holder improvements
- Products routing and module updated for base FD rate chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-05-21 21:15:45 +05:30
co-authored by Claude Sonnet 4.6
parent 846b2484d7
commit 61b0244fa0
37 changed files with 1998 additions and 435 deletions
@@ -0,0 +1,355 @@
<!--
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/.
-->
<div class="page-layout">
<div class="page-header">
<div>
<h2>CBS Base Interest Rate</h2>
<p class="page-subtitle">
Manage master CBS interest rate slabs by deposit type. Saving a new version automatically
<strong>deactivates</strong> the current active version and <strong>activates</strong> the new one. This chart
is used as the reference for both Fixed Deposit and Recurring Deposit products.
</p>
</div>
</div>
<mat-tab-group animationDuration="150ms">
@for (fdType of fdTypes; track fdType) {
<mat-tab [label]="fdType">
<div class="tab-content">
@if (!showPreview[fdType]) {
<!-- ── Editable Slabs Table ── -->
<div class="section-header">
<h3>CBS Base Interest Rate Slabs</h3>
<button type="button" mat-raised-button color="accent" (click)="addSlab(fdType)">
<mat-icon>add</mat-icon> Add Slab
</button>
</div>
@if (slabs(fdType).length === 0) {
<p class="empty-text">No slabs defined. Click "Add Slab" to begin.</p>
} @else {
<form [formGroup]="forms[fdType]">
<table mat-table [dataSource]="slabs(fdType).controls" class="slabs-table">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let row">{{ row.get('srNo').value }}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let row">
<mat-form-field class="cell-field">
<input matInput [formControl]="row.get('description')" placeholder="e.g. 7 Days to 14 Days" />
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="periodType">
<th mat-header-cell *matHeaderCellDef>Period Type</th>
<td mat-cell *matCellDef="let row">
<mat-form-field class="cell-field cell-field--sm">
<mat-select [formControl]="row.get('periodTypeEnum')">
@for (pt of periodTypes; track pt.id) {
<mat-option [value]="pt.id">{{ pt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="fromPeriod">
<th mat-header-cell *matHeaderCellDef>From</th>
<td mat-cell *matCellDef="let row">
<mat-form-field class="cell-field cell-field--xs">
<input matInput type="number" min="0" [formControl]="row.get('fromPeriod')" />
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let row">
<mat-form-field class="cell-field cell-field--xs">
<input matInput type="number" min="0" [formControl]="row.get('toPeriod')" placeholder="∞" />
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="annualRate">
<th mat-header-cell *matHeaderCellDef>Annual Rate (%)</th>
<td mat-cell *matCellDef="let row">
<mat-form-field class="cell-field cell-field--xs">
<input
matInput
type="number"
min="0"
step="0.01"
[formControl]="row.get('annualInterestRate')"
/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="seniorRate">
<th mat-header-cell *matHeaderCellDef>Senior Citizen (%)</th>
<td mat-cell *matCellDef="let row">
<mat-form-field class="cell-field cell-field--xs">
<input
matInput
type="number"
min="0"
step="0.01"
[formControl]="row.get('seniorCitizenRate')"
/>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row; let i = index">
<button type="button" mat-icon-button color="warn" (click)="removeSlab(fdType, i)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="slabColumns"></tr>
<tr mat-row *matRowDef="let row; columns: slabColumns"></tr>
</table>
</form>
}
<!-- ── Version Label & Remarks ── -->
<form [formGroup]="forms[fdType]" class="version-form">
<mat-form-field class="version-field">
<mat-label>New Version Label</mat-label>
<input matInput formControlName="versionLabel" placeholder="e.g. v2.0 May 2026" />
<mat-error>Version label is required.</mat-error>
</mat-form-field>
<mat-form-field class="version-field">
<mat-label>Remarks (optional)</mat-label>
<input matInput formControlName="remarks" />
</mat-form-field>
</form>
<div class="actions-row">
<button
type="button"
mat-raised-button
color="primary"
[disabled]="forms[fdType].invalid || slabs(fdType).length === 0"
(click)="openPreview(fdType)"
>
Preview & Save
</button>
</div>
} @else {
<!-- ── Preview Panel ── -->
<div class="preview-section">
<div class="preview-heading">
<h3>CBS Base Interest Rate — Save Preview ({{ fdType }})</h3>
<p class="page-subtitle">
Review the change before confirming. The current active version will be
<strong class="text-red">deactivated</strong>, and the new version will be
<strong class="text-green">activated</strong>.
</p>
</div>
<div class="preview-grid">
<!-- Current Active (will deactivate) -->
<div class="preview-card preview-card--deactivate">
<div class="preview-card-header">
<span class="active-badge">Currently Active</span>
<span class="deactivate-arrow">→ Will be DEACTIVATED</span>
</div>
@if (activeRates[fdType]) {
<p class="version-meta">{{ activeRates[fdType].versionLabel }}</p>
@if (activeRates[fdType].remarks) {
<p class="remarks-text">{{ activeRates[fdType].remarks }}</p>
}
<table mat-table [dataSource]="activeRates[fdType].slabs" class="preview-table">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let s">{{ s.srNo }}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let s">{{ s.description }}</td>
</ng-container>
<ng-container matColumnDef="periodType">
<th mat-header-cell *matHeaderCellDef>Period</th>
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum) }}</td>
</ng-container>
<ng-container matColumnDef="fromPeriod">
<th mat-header-cell *matHeaderCellDef>From</th>
<td mat-cell *matCellDef="let s">{{ s.fromPeriod }}</td>
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let s">{{ s.toPeriod ?? '∞' }}</td>
</ng-container>
<ng-container matColumnDef="annualRate">
<th mat-header-cell *matHeaderCellDef>Rate (%)</th>
<td mat-cell *matCellDef="let s">{{ s.annualInterestRate | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="seniorRate">
<th mat-header-cell *matHeaderCellDef>Senior (%)</th>
<td mat-cell *matCellDef="let s">{{ s.seniorCitizenRate | number: '1.2-2' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="historySlabColumns"></tr>
<tr mat-row *matRowDef="let row; columns: historySlabColumns"></tr>
</table>
} @else {
<p class="empty-text">No active version currently exists.</p>
}
</div>
<!-- New Version (will activate) -->
<div class="preview-card preview-card--activate">
<div class="preview-card-header">
<span class="inactive-badge">New Version</span>
<span class="activate-arrow">→ Will be ACTIVATED</span>
</div>
<p class="version-meta">{{ forms[fdType].value.versionLabel }}</p>
@if (forms[fdType].value.remarks) {
<p class="remarks-text">{{ forms[fdType].value.remarks }}</p>
}
<table mat-table [dataSource]="slabs(fdType).value" class="preview-table">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let s">{{ s.srNo }}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let s">{{ s.description }}</td>
</ng-container>
<ng-container matColumnDef="periodType">
<th mat-header-cell *matHeaderCellDef>Period</th>
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum) }}</td>
</ng-container>
<ng-container matColumnDef="fromPeriod">
<th mat-header-cell *matHeaderCellDef>From</th>
<td mat-cell *matCellDef="let s">{{ s.fromPeriod }}</td>
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let s">{{ s.toPeriod ?? '∞' }}</td>
</ng-container>
<ng-container matColumnDef="annualRate">
<th mat-header-cell *matHeaderCellDef>Rate (%)</th>
<td mat-cell *matCellDef="let s">{{ s.annualInterestRate | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="seniorRate">
<th mat-header-cell *matHeaderCellDef>Senior (%)</th>
<td mat-cell *matCellDef="let s">{{ s.seniorCitizenRate | number: '1.2-2' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="historySlabColumns"></tr>
<tr mat-row *matRowDef="let row; columns: historySlabColumns"></tr>
</table>
</div>
</div>
<div class="actions-row">
<button type="button" mat-stroked-button (click)="cancelPreview(fdType)">Cancel</button>
<button
type="button"
mat-raised-button
color="primary"
[disabled]="isSaving[fdType]"
(click)="confirmSave(fdType)"
>
{{ isSaving[fdType] ? 'Saving...' : 'Confirm & Activate' }}
</button>
</div>
</div>
}
@if (saveMessage[fdType]) {
<p class="save-message">{{ saveMessage[fdType] }}</p>
}
<!-- ── History ── -->
<div class="history-section">
<div class="section-header">
<div>
<h3>CBS Base Interest Rate — Version History</h3>
<p class="page-subtitle">All past versions for {{ fdType }} deposit type.</p>
</div>
</div>
@if (isLoading) {
<p>Loading history...</p>
} @else if (historyForType(fdType).length === 0) {
<p class="empty-text">No history found.</p>
} @else {
<mat-accordion multi>
@for (version of historyForType(fdType); track version.id) {
<mat-expansion-panel [expanded]="version.isActive">
<mat-expansion-panel-header>
<mat-panel-title>
<span class="version-header">
<strong>{{ version.versionLabel }}</strong>
<span [class]="version.isActive ? 'active-badge' : 'inactive-badge'">
{{ version.isActive ? 'Active' : 'Inactive' }}
</span>
</span>
</mat-panel-title>
<mat-panel-description>
<span class="meta-text">From {{ version.effectiveFromDate }}</span>
<span class="meta-text">To {{ version.effectiveToDate || 'Current' }}</span>
</mat-panel-description>
</mat-expansion-panel-header>
@if (version.remarks) {
<p class="remarks-text">{{ version.remarks }}</p>
}
<table mat-table [dataSource]="version.slabs" class="history-table">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let s">{{ s.srNo }}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let s">{{ s.description }}</td>
</ng-container>
<ng-container matColumnDef="periodType">
<th mat-header-cell *matHeaderCellDef>Period</th>
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum) }}</td>
</ng-container>
<ng-container matColumnDef="fromPeriod">
<th mat-header-cell *matHeaderCellDef>From</th>
<td mat-cell *matCellDef="let s">{{ s.fromPeriod }}</td>
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let s">{{ s.toPeriod ?? '∞' }}</td>
</ng-container>
<ng-container matColumnDef="annualRate">
<th mat-header-cell *matHeaderCellDef>Annual Rate (%)</th>
<td mat-cell *matCellDef="let s">{{ s.annualInterestRate | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="seniorRate">
<th mat-header-cell *matHeaderCellDef>Senior Citizen (%)</th>
<td mat-cell *matCellDef="let s">{{ s.seniorCitizenRate | number: '1.2-2' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="historySlabColumns"></tr>
<tr mat-row *matRowDef="let row; columns: historySlabColumns"></tr>
</table>
</mat-expansion-panel>
}
</mat-accordion>
}
</div>
</div>
</mat-tab>
}
</mat-tab-group>
</div>
@@ -0,0 +1,216 @@
/**
* 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/.
*/
.page-layout {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
padding: 1rem 0 0.5rem;
}
.page-subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
.tab-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem 0.25rem;
}
/* ── Section Header ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
h3 {
margin: 0;
}
}
.empty-text {
color: #9ca3af;
font-style: italic;
}
/* ── Slabs Table ── */
.slabs-table {
width: 100%;
}
.cell-field {
width: 100%;
min-width: 140px;
&--sm {
min-width: 110px;
}
&--xs {
min-width: 80px;
width: 90px;
}
}
/* ── Version Form ── */
.version-form {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.version-field {
flex: 1;
min-width: 200px;
}
/* ── Actions ── */
.actions-row {
display: flex;
align-items: center;
gap: 1rem;
}
.save-message {
color: #16a34a;
font-weight: 600;
}
/* ── Preview Section ── */
.preview-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.preview-heading h3 {
margin: 0 0 0.25rem;
}
.preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
@media (width <= 768px) {
grid-template-columns: 1fr;
}
}
.preview-card {
border-radius: 8px;
padding: 1rem;
border: 2px solid transparent;
&--deactivate {
background: #fff7f7;
border-color: #fca5a5;
}
&--activate {
background: #f0fdf4;
border-color: #86efac;
}
}
.preview-card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.deactivate-arrow {
font-size: 0.8rem;
font-weight: 700;
color: #dc2626;
}
.activate-arrow {
font-size: 0.8rem;
font-weight: 700;
color: #16a34a;
}
.text-red {
color: #dc2626;
}
.text-green {
color: #16a34a;
}
.preview-table {
width: 100%;
}
/* ── History ── */
.history-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.version-header {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.meta-text {
margin-left: 0.75rem;
color: #6b7280;
font-size: 0.85rem;
}
.history-table {
width: 100%;
}
.remarks-text {
margin: 0 0 0.75rem;
color: #374151;
font-size: 0.875rem;
}
.version-meta {
font-weight: 600;
color: #1f2937;
margin: 0 0 0.25rem;
}
/* ── Badges ── */
.active-badge,
.inactive-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.active-badge {
background: #dcfce7;
color: #166534;
}
.inactive-badge {
background: #f3f4f6;
color: #4b5563;
}
@@ -0,0 +1,236 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { DecimalPipe } from '@angular/common';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconButton } from '@angular/material/button';
import { MatExpansionModule } from '@angular/material/expansion';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon';
import { ProductsService } from 'app/products/products.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-base-fd-interest-rate-chart',
templateUrl: './base-fd-interest-rate-chart.component.html',
styleUrls: ['./base-fd-interest-rate-chart.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatIconButton,
MatIconModule,
MatExpansionModule,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
DecimalPipe
]
})
export class BaseFdInterestRateChartComponent implements OnInit {
private fb = inject(FormBuilder);
private productsService = inject(ProductsService);
readonly fdTypes = [
'SINGLE',
'DOUBLE',
'SPECIAL'
];
readonly periodTypes = [
{ id: 0, label: 'Days' },
{ id: 1, label: 'Weeks' },
{ id: 2, label: 'Months' },
{ id: 3, label: 'Years' }
];
readonly slabColumns = [
'srNo',
'description',
'periodType',
'fromPeriod',
'toPeriod',
'annualRate',
'seniorRate',
'actions'
];
readonly historySlabColumns = [
'srNo',
'description',
'periodType',
'fromPeriod',
'toPeriod',
'annualRate',
'seniorRate'
];
forms: Record<string, FormGroup> = {};
activeRates: Record<string, any> = {};
historyRates: any[] = [];
isLoading = false;
isSaving: Record<string, boolean> = {};
saveMessage: Record<string, string> = {};
showPreview: Record<string, boolean> = {};
ngOnInit(): void {
this.fdTypes.forEach((type) => {
this.forms[type] = this.buildForm();
this.isSaving[type] = false;
this.saveMessage[type] = '';
this.showPreview[type] = false;
});
this.loadAll();
}
slabs(fdType: string): FormArray {
return this.forms[fdType].get('slabs') as FormArray;
}
addSlab(fdType: string): void {
const arr = this.slabs(fdType);
arr.push(this.buildSlabRow({ srNo: arr.length + 1 }));
}
removeSlab(fdType: string, index: number): void {
this.slabs(fdType).removeAt(index);
this.slabs(fdType).controls.forEach((c, i) => c.patchValue({ srNo: i + 1 }));
}
openPreview(fdType: string): void {
this.showPreview[fdType] = true;
this.saveMessage[fdType] = '';
}
cancelPreview(fdType: string): void {
this.showPreview[fdType] = false;
}
confirmSave(fdType: string): void {
this.isSaving[fdType] = true;
const { versionLabel, remarks, slabs } = this.forms[fdType].value;
this.productsService.saveFdBaseInterestRates({ fdType, versionLabel, remarks, slabs }).subscribe({
next: () => {
this.saveMessage[fdType] = 'New version activated. Previous version deactivated.';
this.showPreview[fdType] = false;
this.isSaving[fdType] = false;
this.loadAll();
},
error: () => {
this.saveMessage[fdType] = 'Unable to save. Please try again.';
this.isSaving[fdType] = false;
}
});
}
getPeriodLabel(enumVal: number): string {
return this.periodTypes.find((p) => p.id === enumVal)?.label ?? String(enumVal);
}
historyForType(fdType: string): any[] {
return this.historyRates
.filter((v) => v.fdType === fdType)
.sort((a, b) => {
if (a.isActive && !b.isActive) return -1;
if (!a.isActive && b.isActive) return 1;
const dateA = a.effectiveFromDate ? new Date(a.effectiveFromDate).getTime() : 0;
const dateB = b.effectiveFromDate ? new Date(b.effectiveFromDate).getTime() : 0;
return dateB - dateA;
});
}
private loadAll(): void {
this.isLoading = true;
this.fdTypes.forEach((type) => {
this.productsService.getFdBaseInterestRates(type).subscribe({
next: (rates: any[]) => {
const active = (rates || []).find((r: any) => r.isActive);
this.activeRates[type] = active ?? null;
const arr = this.slabs(type);
arr.clear();
(active?.slabs ?? []).forEach((s: any) => arr.push(this.buildSlabRow(s)));
this.forms[type].patchValue({ versionLabel: '', remarks: '' });
},
error: () => {
this.activeRates[type] = null;
}
});
});
this.productsService.getFdBaseInterestRateHistory().subscribe({
next: (h: any[]) => {
this.historyRates = h || [];
this.isLoading = false;
},
error: () => {
this.historyRates = [];
this.isLoading = false;
}
});
}
private buildForm(): FormGroup {
return this.fb.group({
versionLabel: [
'',
Validators.required
],
remarks: [''],
slabs: this.fb.array([])
});
}
private buildSlabRow(d?: any): FormGroup {
return this.fb.group({
srNo: [d?.srNo ?? 1],
description: [
d?.description ?? '',
Validators.required
],
periodTypeEnum: [
d?.periodTypeEnum ?? 2,
Validators.required
],
fromPeriod: [
d?.fromPeriod ?? 0,
[
Validators.required,
Validators.min(0)
]
],
toPeriod: [d?.toPeriod ?? null],
annualInterestRate: [
d?.annualInterestRate ?? 0,
[
Validators.required,
Validators.min(0)
]
],
seniorCitizenRate: [
d?.seniorCitizenRate ?? 0,
Validators.min(0)
]
});
}
}
@@ -103,6 +103,7 @@ import { EditProductMixComponent } from './products-mix/edit-product-mix/edit-pr
import { ChargesTemplateAndResolver } from './charges/charges-template-and-resolver';
import { EditFixedDepositProductComponent } from './fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component';
import { FixedDepositProductAndTemplateResolver } from './fixed-deposit-products/edit-fixed-deposit-product/fixed-deposit-product-and-template.resolver';
import { BaseFdInterestRateChartComponent } from './fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component';
import { CollateralResolver } from './collaterals/collateral.resolver';
import { CollateralsResolver } from './collaterals/collaterals.resolver';
import { CollateralTemplateResolver } from './collaterals/collaterals-template.resolver';
@@ -727,6 +728,11 @@ const routes: Routes = [
path: 'fixed-deposit-products',
data: { title: 'Fixed Deposit Products', breadcrumb: 'Fixed Deposit Products' },
children: [
{
path: 'base-interest-rates',
component: BaseFdInterestRateChartComponent,
data: { title: 'CBS Base Interest Rate', breadcrumb: 'CBS Base Interest Rate' }
},
{
path: 'create',
component: CreateFixedDepositProductComponent,
+19
View File
@@ -87,6 +87,25 @@
</a>
</div>
<div>
<a
class="pd-card"
[routerLink]="['fixed-deposit-products', 'base-interest-rates']"
*mifosxHasPermission="'READ_FIXEDDEPOSITPRODUCT'"
>
<div class="pd-card__icon pd-card__icon--amber">
<i class="fas fa-percentage"></i>
</div>
<div class="pd-card__body">
<h3 class="pd-card__title">CBS Base Interest Rate</h3>
<p class="pd-card__desc">
Manage and activate CBS base interest rate slabs used for Fixed and Recurring Deposit products
</p>
</div>
<i class="fas fa-chevron-right pd-card__arrow"></i>
</a>
</div>
<div #recurringDepositProducts>
<a
class="pd-card"
-2
View File
@@ -84,7 +84,6 @@ import { ViewTaxGroupComponent } from './manage-tax-groups/view-tax-group/view-t
import { ShareProductsDividendsComponent } from './share-products/dividends-share-product/dividends.components';
import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component';
import { CreateRecurringDepositProductComponent } from './recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component';
import { BaseRecurringDepositInterestRateChartComponent } from './recurring-deposit-products/base-recurring-deposit-interest-rate-chart/base-recurring-deposit-interest-rate-chart.component';
import { RecurringDepositProductAccountingStepComponent } from './recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component';
import { RecurringDepositProductChargesStepComponent } from './recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-charges-step/recurring-deposit-product-charges-step.component';
import { RecurringDepositProductCurrencyStepComponent } from './recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component';
@@ -214,7 +213,6 @@ import { AdvancedAccountingMappingRuleComponent } from './loan-products/loan-pro
DepositProductIncentiveFormDialogComponent,
ViewRecurringDepositProductComponent,
CreateRecurringDepositProductComponent,
BaseRecurringDepositInterestRateChartComponent,
RecurringDepositProductAccountingStepComponent,
RecurringDepositProductChargesStepComponent,
RecurringDepositProductCurrencyStepComponent,
@@ -1,119 +0,0 @@
<!--
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/.
-->
<div class="page-layout">
<div class="page-header">
<div>
<h2>CBS Base Recurring Deposit Interest Chart</h2>
<p class="page-subtitle">This chart is the master template used when creating a recurring deposit product.</p>
</div>
</div>
@if (recurringDepositProductsTemplate?.chartTemplate) {
<mifosx-recurring-deposit-product-interest-rate-chart-step
#stepComponent
[recurringDepositProductsTemplate]="recurringDepositProductsTemplate"
[rdInterestRates]="rdInterestRates"
>
</mifosx-recurring-deposit-product-interest-rate-chart-step>
<div class="actions-row">
<button
type="button"
mat-raised-button
color="primary"
[disabled]="isSaving || !stepComponent"
(click)="save(stepComponent)"
>
{{ isSaving ? 'Saving...' : 'Save CBS Base Chart' }}
</button>
@if (saveMessage) {
<span class="save-message">{{ saveMessage }}</span>
}
</div>
} @else {
<p>Loading CBS base chart...</p>
}
<div class="history-section">
<div class="section-header">
<div>
<h3>Chart History</h3>
<p class="section-subtitle">Previous CBS base chart versions are listed here for comparison and audit.</p>
</div>
</div>
@if (isHistoryLoading) {
<p>Loading history...</p>
} @else if (rdInterestRateHistory.length === 0) {
<p>No history found for the CBS base chart.</p>
} @else {
<mat-accordion multi>
@for (version of rdInterestRateHistory; track version.id) {
<mat-expansion-panel [expanded]="version.isActive">
<mat-expansion-panel-header>
<mat-panel-title>
<span class="version-header">
<strong>{{ version.versionLabel }}</strong>
<span [class]="version.isActive ? 'active-badge' : 'inactive-badge'">
{{ version.isActive ? 'Active' : 'Inactive' }}
</span>
</span>
</mat-panel-title>
<mat-panel-description>
<span class="meta-text">From {{ version.effectiveFromDate }}</span>
<span class="meta-text">To {{ version.effectiveToDate || 'Current' }}</span>
</mat-panel-description>
</mat-expansion-panel-header>
@if (version.remarks) {
<p class="remarks-text">{{ version.remarks }}</p>
}
<table class="history-table" mat-table [dataSource]="version.slabs">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>Sr.</th>
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>Description</th>
<td mat-cell *matCellDef="let slab">{{ slab.description }}</td>
</ng-container>
<ng-container matColumnDef="fromPeriod">
<th mat-header-cell *matHeaderCellDef>From</th>
<td mat-cell *matCellDef="let slab">{{ slab.fromPeriod }}</td>
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let slab">
{{ slab.toPeriod !== null && slab.toPeriod !== undefined ? slab.toPeriod : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="periodTypeLabel">
<th mat-header-cell *matHeaderCellDef>Period Type</th>
<td mat-cell *matCellDef="let slab">{{ slab.periodTypeLabel }}</td>
</ng-container>
<ng-container matColumnDef="annualInterestRate">
<th mat-header-cell *matHeaderCellDef>Annual Rate (%)</th>
<td mat-cell *matCellDef="let slab">{{ slab.annualInterestRate | number: '1.2-2' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="historySlabColumns"></tr>
<tr mat-row *matRowDef="let row; columns: historySlabColumns"></tr>
</table>
</mat-expansion-panel>
}
</mat-accordion>
}
</div>
</div>
@@ -1,95 +0,0 @@
/**
* 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/.
*/
.page-layout {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.page-header {
display: flex;
align-items: end;
justify-content: space-between;
}
.page-subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.actions-row {
display: flex;
align-items: center;
gap: 1rem;
}
.save-message {
color: #2563eb;
font-weight: 600;
}
.history-section {
margin-top: 1rem;
padding-top: 0.5rem;
border-top: 1px solid #e5e7eb;
}
.section-header {
display: flex;
align-items: end;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.version-header {
display: inline-flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.meta-text {
margin-left: 0.75rem;
color: #6b7280;
font-size: 0.85rem;
}
.active-badge,
.inactive-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.active-badge {
background: #dcfce7;
color: #166534;
}
.inactive-badge {
background: #f3f4f6;
color: #4b5563;
}
.remarks-text {
margin: 0 0 0.75rem;
color: #374151;
}
.history-table {
width: 100%;
}
@@ -1,121 +0,0 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { ProductsService } from 'app/products/products.service';
import { RecurringDepositProductInterestRateChartStepComponent } from '../recurring-deposit-product-stepper/recurring-deposit-product-interest-rate-chart-step/recurring-deposit-product-interest-rate-chart-step.component';
import { MatButton } from '@angular/material/button';
import { DecimalPipe } from '@angular/common';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-base-recurring-deposit-interest-rate-chart',
templateUrl: './base-recurring-deposit-interest-rate-chart.component.html',
styleUrls: ['./base-recurring-deposit-interest-rate-chart.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatButton,
MatExpansionModule,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
DecimalPipe,
RecurringDepositProductInterestRateChartStepComponent
]
})
export class BaseRecurringDepositInterestRateChartComponent implements OnInit {
private productsService = inject(ProductsService);
recurringDepositProductsTemplate: any;
rdInterestRates: any[] = [];
rdInterestRateHistory: any[] = [];
readonly historySlabColumns = [
'srNo',
'description',
'fromPeriod',
'toPeriod',
'periodTypeLabel',
'annualInterestRate'
];
isSaving = false;
isHistoryLoading = false;
saveMessage = '';
ngOnInit(): void {
this.productsService.getRecurringDepositProductsTemplate().subscribe({
next: (template: any) => (this.recurringDepositProductsTemplate = template),
error: () => (this.recurringDepositProductsTemplate = null)
});
this.loadBaseRates();
this.loadBaseRateHistory();
}
get chartStep(): RecurringDepositProductInterestRateChartStepComponent | null {
return null;
}
save(stepComponent: RecurringDepositProductInterestRateChartStepComponent): void {
this.isSaving = true;
this.saveMessage = '';
const payload = stepComponent.modifiedRdInterestRatePayload;
this.productsService.saveRdBaseInterestRates(payload).subscribe({
next: () => {
this.saveMessage = 'CBS base chart saved successfully.';
this.loadBaseRates();
this.loadBaseRateHistory();
this.isSaving = false;
},
error: () => {
this.saveMessage = 'Unable to save CBS base chart.';
this.isSaving = false;
}
});
}
private loadBaseRates(): void {
this.productsService.getRdBaseInterestRates().subscribe({
next: (rates: any[]) => (this.rdInterestRates = rates || []),
error: () => (this.rdInterestRates = [])
});
}
private loadBaseRateHistory(): void {
this.isHistoryLoading = true;
this.productsService.getRdBaseInterestRateHistory().subscribe({
next: (history: any[]) => {
this.rdInterestRateHistory = history || [];
this.isHistoryLoading = false;
},
error: () => {
this.rdInterestRateHistory = [];
this.isHistoryLoading = false;
}
});
}
}