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
@@ -106,20 +106,38 @@
<div class="arc-step-label arc-step-label--mt">
<span class="arc-step-badge">2</span>
<span>Ownership Share</span>
@if (maxShare < 100) {
<span class="arc-remaining-badge">Max available: {{ maxShare }}%</span>
}
</div>
<div class="arc-share-row">
<mat-form-field appearance="outline" class="arc-share-field">
<mat-label>Share Percentage</mat-label>
<input matInput type="number" formControlName="sharePercentage" min="1" max="100" />
<input matInput type="number" formControlName="sharePercentage" min="1" [max]="maxShare" />
<span matSuffix class="arc-percent-suffix">%</span>
<mat-error>Enter a value between 1 and 100</mat-error>
@if (form.get('sharePercentage').hasError('max')) {
<mat-error>Maximum available is {{ maxShare }}%</mat-error>
} @else {
<mat-error>Enter a value between 1 and {{ maxShare }}</mat-error>
}
</mat-form-field>
<div class="arc-share-hints">
@for (hint of [25, 33, 50, 100]; track hint) {
<button type="button" class="arc-hint-chip" (click)="form.get('sharePercentage').setValue(hint)">
{{ hint }}%
@if (hint <= maxShare) {
<button type="button" class="arc-hint-chip" (click)="form.get('sharePercentage').setValue(hint)">
{{ hint }}%
</button>
}
}
@if (maxShare > 0 && ![25, 33, 50, 100].includes(maxShare)) {
<button
type="button"
class="arc-hint-chip arc-hint-chip--remaining"
(click)="form.get('sharePercentage').setValue(maxShare)"
>
{{ maxShare }}%
</button>
}
</div>
@@ -102,6 +102,19 @@ mifosx-add-related-client-dialog {
}
}
.arc-remaining-badge {
margin-left: auto;
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
text-transform: none;
letter-spacing: 0;
}
.arc-step-badge {
width: 20px;
height: 20px;
@@ -350,6 +363,18 @@ mifosx-add-related-client-dialog {
background: $primary-light;
color: $primary;
}
&--remaining {
border-color: #f59e0b;
background: #fffbeb;
color: #92400e;
&:hover {
border-color: #d97706;
background: #fef3c7;
color: #78350f;
}
}
}
// ── Actions ───────────────────────────────────────────────────────────────────
@@ -32,16 +32,21 @@ export class AddRelatedClientDialogComponent implements OnDestroy {
private fb = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
dialogRef = inject(MatDialogRef<AddRelatedClientDialogComponent>);
data: { excludedClientIds?: number[] } = inject(MAT_DIALOG_DATA, { optional: true }) ?? {};
data: { excludedClientIds?: number[]; maxSharePercentage?: number } =
inject(MAT_DIALOG_DATA, { optional: true }) ?? {};
get maxShare(): number {
return this.data?.maxSharePercentage ?? 100;
}
form: UntypedFormGroup = this.fb.group({
searchText: [''],
sharePercentage: [
null,
this.data?.maxSharePercentage ?? null,
[
Validators.required,
Validators.min(1),
Validators.max(100)
Validators.max(this.data?.maxSharePercentage ?? 100)
]
],
signingAuthority: [null],
@@ -259,6 +259,7 @@
color="primary"
type="button"
class="soa-add-btn"
[disabled]="totalJointSharePercentage >= 100"
(click)="openAddJointHolderDialog()"
>
<mat-icon>person_add</mat-icon> Add Holder
@@ -761,6 +762,7 @@
color="primary"
type="button"
class="ni-add-btn"
[disabled]="totalSharePercentage >= 100"
(click)="openAddRelatedClientDialog()"
>
<mat-icon>person_add</mat-icon> Add Partner
@@ -364,11 +364,12 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
...(this.primaryClientId ? [this.primaryClientId] : []),
...this.jointHolders.map((h: any) => h.clientId).filter(Boolean)
];
const maxSharePercentage = Math.max(0, 100 - this.totalJointSharePercentage);
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true,
data: { excludedClientIds }
data: { excludedClientIds, maxSharePercentage }
});
ref.afterClosed().subscribe((result) => {
if (result) {
@@ -443,11 +444,12 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
...(this.primaryClientId ? [this.primaryClientId] : []),
...this.relatedClients.map((c: any) => c.clientId).filter(Boolean)
];
const maxSharePercentage = Math.max(0, 100 - this.totalSharePercentage);
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true,
data: { excludedClientIds }
data: { excludedClientIds, maxSharePercentage }
});
ref.afterClosed().subscribe((result) => {
if (result) {
@@ -74,6 +74,7 @@
<mifosx-fixed-deposit-account-interest-rate-chart-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[cbsBaseRates]="cbsBaseRates"
(tenureSelected)="onTenureSelected($event)"
>
</mifosx-fixed-deposit-account-interest-rate-chart-step>
@@ -14,6 +14,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FixedDepositsService } from '../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { ProductsService } from 'app/products/products.service';
/** Custom Components */
import { FixedDepositAccountDetailsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
@@ -61,6 +62,7 @@ export class CreateFixedDepositAccountComponent {
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
private productsService = inject(ProductsService);
@ViewChild(FixedDepositAccountDetailsStepComponent, { static: true })
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@@ -71,6 +73,8 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountTemplate: any;
/** Fixed Deposit Account Product Template */
fixedDepositsAccountProductTemplate: any;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
selectedTenure: {
depositPeriod: number;
@@ -79,6 +83,7 @@ export class CreateFixedDepositAccountComponent {
maturityAmount?: number;
interestEarned?: number;
appliedRate?: number;
isSeniorCitizen?: boolean;
} | null = null;
/**
@@ -101,6 +106,11 @@ export class CreateFixedDepositAccountComponent {
*/
setTemplate($event: any) {
this.fixedDepositsAccountProductTemplate = $event;
const fdType = $event?.fdType;
this.productsService.getFdBaseInterestRates(fdType).subscribe({
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
error: () => (this.cbsBaseRates = [])
});
}
onTenureSelected($event: {
@@ -110,6 +120,7 @@ export class CreateFixedDepositAccountComponent {
maturityAmount?: number;
interestEarned?: number;
appliedRate?: number;
isSeniorCitizen?: boolean;
}) {
this.selectedTenure = $event;
}
@@ -257,6 +268,17 @@ export class CreateFixedDepositAccountComponent {
}));
}
// Senior citizen rate override
if (
this.selectedTenure?.isSeniorCitizen &&
this.selectedTenure.appliedRate != null &&
this.selectedTenure.appliedRate > 0
) {
fixedDepositAccount.isSeniorCitizen = true;
fixedDepositAccount.seniorCitizenInterestRate = this.selectedTenure.appliedRate;
fixedDepositAccount.nominalAnnualInterestRate = this.selectedTenure.appliedRate;
}
this.fixedDepositsService.createFixedDepositAccount(fixedDepositAccount).subscribe({
next: (response: any) => {
this.router.navigate(
@@ -370,6 +370,7 @@
color="primary"
type="button"
class="soa-add-btn"
[disabled]="totalJointSharePercentage >= 100"
(click)="openAddJointHolderDialog()"
>
<mat-icon>person_add</mat-icon> Add Holder
@@ -473,11 +473,12 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
...(this.primaryClientId ? [this.primaryClientId] : []),
...this.jointHolders.map((h: any) => h.clientId).filter(Boolean)
];
const maxSharePercentage = Math.max(0, 100 - this.totalJointSharePercentage);
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true,
data: { excludedClientIds }
data: { excludedClientIds, maxSharePercentage }
});
ref.afterClosed().subscribe((result) => {
if (result) {
@@ -8,7 +8,7 @@
<div class="fdrc-wrapper">
<!-- ══ Chart Header Card ════════════════════════════════════════════════ -->
@if (fixedDepositsAccountProductTemplate?.accountChart) {
@if (interestRateChartData.length > 0) {
<div class="fdrc-header-card">
<div class="fdrc-header-card__left">
<div class="fdrc-header-card__icon">
@@ -163,6 +163,18 @@
</div>
</div>
<!-- Senior Citizen -->
<div class="fdrc-calc-field fdrc-calc-field--senior">
<label class="fdrc-senior-label">
<input type="checkbox" [(ngModel)]="isSeniorCitizen" (change)="onSeniorCitizenChange()" />
<span class="fdrc-senior-text">
<i class="fas fa-user-shield"></i>
Senior Citizen
</span>
<span class="fdrc-senior-hint">Higher rate applied if available</span>
</label>
</div>
<div class="fdrc-calc-actions">
<button type="button" class="fdrc-calc-btn" (click)="calculateRate()">
<i class="fas fa-search"></i> Find Rate
@@ -186,34 +198,80 @@
<!-- Match Result -->
@if (matchResult) {
<div class="fdrc-result-card">
<div class="fdrc-result-card__check">
<i class="fas fa-check-circle"></i>
</div>
<div class="fdrc-result-card__body">
<!-- Selected Tenure Banner -->
<!-- Tenure Banner -->
<div class="fdrc-tenure-banner">
<i class="fas fa-calendar-check"></i>
<div class="fdrc-tenure-banner__content">
<span class="fdrc-tenure-banner__label">Your Selected FD Tenure</span>
<span class="fdrc-tenure-banner__value">{{ tenureValue }} {{ tenurePeriodUnit }}</span>
</div>
<span class="fdrc-tenure-banner__saved"> <i class="fas fa-save"></i> Will be saved </span>
<span class="fdrc-tenure-banner__saved"><i class="fas fa-save"></i> Will be saved</span>
</div>
<div class="fdrc-result-rate">
<span class="fdrc-result-rate__value">{{ matchResult.appliedRate | number: '1.2-2' }}%</span>
<span class="fdrc-result-rate__label">Annual Interest Rate</span>
</div>
<!-- Senior Citizen Status — always shown when checked -->
@if (isSeniorCitizen) {
@if ((matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<div class="fdrc-sc-status fdrc-sc-status--applied">
<i class="fas fa-user-shield fdrc-sc-status__icon"></i>
<span class="fdrc-sc-status__text">Senior Citizen Rate Applied</span>
<span class="fdrc-sc-status__bonus">
+{{
(matchResult.slab.seniorCitizenRate ?? 0) - (matchResult.slab.annualInterestRate ?? 0)
| number: '1.2-2'
}}% bonus
</span>
</div>
} @else {
<div class="fdrc-sc-status fdrc-sc-status--notice">
<i class="fas fa-info-circle fdrc-sc-status__icon"></i>
<span class="fdrc-sc-status__text"
>Senior Citizen selected — no additional rate configured for this slab, normal rate applied</span
>
</div>
}
}
<!-- Rate Display -->
@if (isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<!-- Before / After comparison -->
<div class="fdrc-rate-compare">
<div class="fdrc-rate-compare__block">
<span class="fdrc-rate-compare__tag">Normal Rate</span>
<span class="fdrc-rate-compare__val fdrc-rate-compare__val--strike"
>{{ matchResult.slab.annualInterestRate | number: '1.2-2' }}%</span
>
<span class="fdrc-rate-compare__sub">p.a.</span>
</div>
<div class="fdrc-rate-compare__arrow"><i class="fas fa-long-arrow-alt-right"></i></div>
<div class="fdrc-rate-compare__block fdrc-rate-compare__block--sc">
<span class="fdrc-rate-compare__tag fdrc-rate-compare__tag--sc"
><i class="fas fa-user-shield"></i> Sr. Citizen Rate</span
>
<span class="fdrc-rate-compare__val fdrc-rate-compare__val--sc"
>{{ matchResult.appliedRate | number: '1.2-2' }}%</span
>
<span class="fdrc-rate-compare__sub">p.a.</span>
</div>
</div>
} @else {
<div class="fdrc-result-rate">
<span class="fdrc-result-rate__value">{{ matchResult.appliedRate | number: '1.2-2' }}%</span>
<span class="fdrc-result-rate__label">Annual Interest Rate p.a.</span>
</div>
}
<!-- Slab Info -->
<div class="fdrc-result-meta">
<div class="fdrc-result-meta__item">
<i class="fas fa-layer-group"></i>
<span
>Applicable Slab:
<strong>
{{ matchResult.slab.fromPeriod }}–{{ matchResult.slab.toPeriod ?? '∞' }}
{{ matchResult.slab.periodType?.value }}
</strong>
</span>
<strong
>{{ matchResult.slab.fromPeriod }}–{{ matchResult.slab.toPeriod ?? '∞' }}
{{ matchResult.slab.periodType?.value }}</strong
></span
>
</div>
@if (matchResult.slab.description) {
<div class="fdrc-result-meta__item">
@@ -230,18 +288,21 @@
>{{ matchResult.slab.amountRangeFrom | number }}–{{
matchResult.slab.amountRangeTo | number
}}</strong
>
</span>
></span
>
</div>
}
</div>
<!-- Maturity Summary (all FD types) -->
@if (matchResult.maturityAmount !== null) {
<!-- Maturity Summary -->
@if (matchResult.maturityAmount) {
<div class="fdrc-maturity-card">
<div class="fdrc-maturity-card__header">
<i class="fas fa-coins"></i>
<span>Maturity Summary</span>
@if (isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<span class="fdrc-sc-tag"><i class="fas fa-user-shield"></i> Sr. Citizen Rate</span>
}
</div>
<div class="fdrc-maturity-card__body">
<div class="fdrc-maturity-row">
@@ -252,15 +313,24 @@
<span class="fdrc-maturity-row__label">Tenure</span>
<span class="fdrc-maturity-row__value">{{ tenureValue }} {{ tenurePeriodUnit }}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Rate</span>
<div
class="fdrc-maturity-row"
[class.fdrc-maturity-row--sc]="isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0"
>
<span class="fdrc-maturity-row__label">
@if (isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<i class="fas fa-user-shield"></i> Sr. Citizen Rate
} @else {
Interest Rate
}
</span>
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Interest Earned</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
</div>
@if (isSpecial && matchResult.estimatedMonthlyPayout !== null) {
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly Payout</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
@@ -350,6 +420,22 @@
</td>
</ng-container>
<!-- Senior Citizen Rate Column -->
<ng-container matColumnDef="seniorCitizenRate">
<th mat-header-cell *matHeaderCellDef class="fdrc-th">
<i class="fas fa-user-shield" style="margin-right: 4px; color: #6366f1"></i>Sr. Citizen Rate
</th>
<td mat-cell *matCellDef="let slab" class="fdrc-td">
@if (slab.seniorCitizenRate > 0) {
<span class="fdrc-rate-badge fdrc-rate-badge--senior"
>{{ slab.seniorCitizenRate | number: '1.2-2' }}%</span
>
} @else {
<span class="fdrc-td--na">—</span>
}
</td>
</ng-container>
<!-- Description Column -->
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef class="fdrc-th">Description</th>
@@ -387,8 +387,7 @@ $red-light: #fee2e2;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
margin-top: 22px;
flex: 0 0 100%;
}
.fdrc-calc-btn {
@@ -515,25 +514,153 @@ $red-light: #fee2e2;
// ── Result Card ───────────────────────────────────────────────────────────────
.fdrc-result-card {
display: flex;
align-items: flex-start;
gap: 16px;
margin-top: 16px;
padding: 18px 20px;
background: $green-light;
border: 1.5px solid rgba($green, 0.35);
border-radius: 12px;
&__check {
font-size: 26px;
color: $green;
flex-shrink: 0;
margin-top: 2px;
&__body {
display: flex;
flex-direction: column;
gap: 12px;
}
}
// ── Senior Citizen Status Banner ──────────────────────────────────────────
.fdrc-sc-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
&--applied {
background: #ede9fe;
border: 1.5px solid #a78bfa;
color: #4c1d95;
}
&__body {
&--notice {
background: #fef3c7;
border: 1.5px solid #fcd34d;
color: #78350f;
}
&__icon {
font-size: 16px;
flex-shrink: 0;
}
&__text {
flex: 1;
min-width: 0;
}
&__bonus {
margin-left: auto;
flex-shrink: 0;
background: #7c3aed;
color: #fff;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
}
// ── Rate Comparison Block (Normal → Senior) ───────────────────────────────
.fdrc-rate-compare {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
&__block {
display: flex;
flex-direction: column;
gap: 3px;
}
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: $gray-200;
color: $gray-600;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
width: fit-content;
&--sc {
background: #ede9fe;
color: #5b21b6;
}
}
&__val {
font-size: 30px;
font-weight: 800;
line-height: 1;
&--strike {
color: $gray-600;
text-decoration: line-through;
}
&--sc {
color: #5b21b6;
}
}
&__sub {
font-size: 11px;
color: $gray-600;
}
&__arrow {
font-size: 22px;
color: $gray-600;
padding-bottom: 8px;
flex-shrink: 0;
}
}
// ── SC Tag (maturity card header) ─────────────────────────────────────────
.fdrc-sc-tag {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
padding: 3px 10px;
background: #ede9fe;
color: #5b21b6;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
// ── SC Maturity Row ───────────────────────────────────────────────────────
.fdrc-maturity-row--sc {
.fdrc-maturity-row__label {
color: #5b21b6;
font-weight: 600;
}
.fdrc-maturity-row__value {
color: #5b21b6;
font-weight: 700;
}
}
@@ -754,6 +881,151 @@ $red-light: #fee2e2;
font-weight: 700;
}
.fdrc-rate-badge--senior {
background: #ede9fe;
color: #5b21b6;
}
// ── Senior Citizen Rate Comparison View ───────────────────────────────────
.fdrc-senior-rate-view {
margin-bottom: 12px;
&__row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
flex-wrap: wrap;
}
&__normal {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
opacity: 0.65;
}
&__senior {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: $gray-200;
color: $gray-600;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
&--sr {
background: #ede9fe;
color: #5b21b6;
}
}
&__strike {
font-size: 24px;
font-weight: 700;
color: $gray-600;
text-decoration: line-through;
line-height: 1;
}
&__applied {
font-size: 32px;
font-weight: 800;
color: #5b21b6;
line-height: 1;
}
&__arrow {
font-size: 18px;
color: $gray-600;
flex-shrink: 0;
align-self: center;
margin-top: 16px;
}
&__bonus {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
background: #ede9fe;
color: #5b21b6;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
i {
font-size: 13px;
}
}
}
.fdrc-senior-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 8px;
background: #ede9fe;
color: #5b21b6;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.fdrc-calc-field--senior {
flex: 0 0 100%;
display: flex;
align-items: center;
}
.fdrc-senior-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #6366f1;
cursor: pointer;
}
}
.fdrc-senior-text {
display: inline-flex;
align-items: center;
gap: 5px;
font-weight: 600;
color: #4338ca;
font-size: 13px;
i {
color: #6366f1;
}
}
.fdrc-senior-hint {
font-size: 11px;
color: #94a3b8;
}
.fdrc-incentive-btn {
display: inline-flex;
align-items: center;
@@ -71,6 +71,8 @@ export interface SlabMatchResult {
export class FixedDepositAccountInterestRateChartStepComponent implements OnChanges {
@Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any;
/** Active CBS base interest rate versions — when provided, overrides the product chart slabs */
@Input() cbsBaseRates: any[] = [];
@Output() tenureSelected = new EventEmitter<{
depositPeriod: number;
@@ -79,6 +81,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
maturityAmount?: number;
interestEarned?: number;
appliedRate?: number;
isSeniorCitizen?: boolean;
}>();
interestRateChartData: any[] = [];
@@ -87,9 +90,12 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
'period',
'amountRange',
'interest',
'seniorCitizenRate',
'description',
'actions'
];
isSeniorCitizen = false;
incentivesDisplayedColumns: string[] = [
'entityType',
'attributeName',
@@ -151,17 +157,26 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
ngOnChanges() {
if (this.fixedDepositsAccountProductTemplate) {
this.interestRateChartData = this.fixedDepositsAccountProductTemplate.accountChart?.chartSlabs ?? [];
const fdType = this.fixedDepositsAccountProductTemplate?.fdType;
const activeBase = (this.cbsBaseRates || []).find((r) => r.fdType === fdType && r.isActive);
this.interestRateChartData = activeBase?.slabs
? this.normalizeCbsSlabs(activeBase.slabs)
: (this.fixedDepositsAccountProductTemplate.accountChart?.chartSlabs ?? []);
if (this.chartsTableRef) {
this.chartsTableRef.renderRows();
}
// Reset calculator when product changes
this.resetCalculator();
// Set default period unit based on FD type
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
}
}
private normalizeCbsSlabs(slabs: any[]): any[] {
return (slabs || []).map((s) => ({
...s,
periodType: { id: s.periodTypeEnum, value: s.periodTypeLabel }
}));
}
// Convert entered tenure to Days for slab matching
private toDays(value: number, unit: FdPeriodUnit): number {
if (unit === 'Days') return value;
@@ -229,7 +244,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
return;
}
const rate = matchedSlab.annualInterestRate ?? 0;
const rate =
this.isSeniorCitizen && (matchedSlab.seniorCitizenRate ?? 0) > 0
? (matchedSlab.seniorCitizenRate ?? 0)
: (matchedSlab.annualInterestRate ?? 0);
const result: SlabMatchResult = {
slab: matchedSlab,
slabIndex: matchedIndex,
@@ -266,7 +284,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
depositAmount: this.principalAmount || undefined,
maturityAmount: result.maturityAmount,
interestEarned: result.interestEarned,
appliedRate: result.appliedRate
appliedRate: result.appliedRate,
isSeniorCitizen: this.isSeniorCitizen
});
}
}
@@ -294,12 +313,20 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
this.validationWarning = null;
}
onSeniorCitizenChange() {
if (this.tenureValue && this.tenureValue > 0) {
this.calculateRate();
} else {
this.matchResult = null;
}
}
isMatchedSlab(index: number): boolean {
return this.matchResult?.slabIndex === index;
}
get chartName(): string {
return this.fixedDepositsAccountProductTemplate?.accountChart?.name || '';
return this.fixedDepositsAccountProductTemplate?.accountChart?.name || 'CBS Base Interest Rate';
}
get chartFromDate(): any {
@@ -488,7 +488,26 @@
<div class="preview-body">
<!-- Selected Tenure + Rate Banner -->
@if (selectedTenure && matchedSlabIndex >= 0) {
<div class="preview-tenure-banner">
<!-- Senior Citizen Status Banner -->
@if (isSeniorCitizenSelected) {
@if (seniorCitizenBonus > 0) {
<div class="preview-sc-status preview-sc-status--applied">
<i class="fas fa-user-shield"></i>
<span>Senior Citizen Rate Applied</span>
<span class="preview-sc-status__bonus">+{{ seniorCitizenBonus | formatNumber }}% bonus</span>
</div>
} @else {
<div class="preview-sc-status preview-sc-status--notice">
<i class="fas fa-info-circle"></i>
<span>Senior Citizen selected — no additional rate configured for this slab</span>
</div>
}
}
<div
class="preview-tenure-banner"
[class.preview-tenure-banner--sc]="isSeniorCitizenSelected && seniorCitizenBonus > 0"
>
<div class="preview-tenure-banner__left">
<i class="fas fa-calendar-check"></i>
<div>
@@ -498,9 +517,17 @@
</div>
<div class="preview-tenure-banner__divider"></div>
<div class="preview-tenure-banner__right">
<span class="preview-tenure-banner__rate-label">Applicable Interest Rate</span>
<span class="preview-tenure-banner__rate"
>{{ interestRateChartData[matchedSlabIndex]?.annualInterestRate | formatNumber }}%</span
<span class="preview-tenure-banner__rate-label">
@if (isSeniorCitizenSelected && seniorCitizenBonus > 0) {
<i class="fas fa-user-shield" style="color: #5b21b6; margin-right: 4px"></i>Sr. Citizen Rate
} @else {
Applicable Interest Rate
}
</span>
<span
class="preview-tenure-banner__rate"
[style.color]="isSeniorCitizenSelected && seniorCitizenBonus > 0 ? '#5b21b6' : ''"
>{{ appliedInterestRate | formatNumber }}%</span
>
</div>
<div class="preview-tenure-banner__slab">
@@ -253,6 +253,54 @@
border-left: 3px solid #16a34a;
}
// ── Senior Citizen Status Banner (preview) ────────────────────────────────────
.preview-sc-status {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
&--applied {
background: #ede9fe;
border: 1.5px solid #a78bfa;
color: #4c1d95;
}
&--notice {
background: #fef3c7;
border: 1.5px solid #fcd34d;
color: #78350f;
}
&__bonus {
margin-left: auto;
flex-shrink: 0;
background: #7c3aed;
color: #fff;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
}
.preview-tenure-banner--sc {
border-color: #a78bfa;
background: #f5f3ff;
.preview-tenure-banner__left > i {
color: #5b21b6;
}
.preview-tenure-banner__divider {
background: #a78bfa;
}
}
// ── Legend below table ────────────────────────────────────────────────────────
.preview-slab-legend {
display: flex;
@@ -85,8 +85,26 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
maturityAmount?: number;
interestEarned?: number;
appliedRate?: number;
isSeniorCitizen?: boolean;
} | null = null;
get appliedInterestRate(): number {
if (this.selectedTenure?.appliedRate != null) return this.selectedTenure.appliedRate;
if (this.matchedSlabIndex >= 0) return this.interestRateChartData[this.matchedSlabIndex]?.annualInterestRate ?? 0;
return 0;
}
get isSeniorCitizenSelected(): boolean {
return !!this.selectedTenure?.isSeniorCitizen;
}
get seniorCitizenBonus(): number {
if (!this.isSeniorCitizenSelected || this.selectedTenure?.appliedRate == null) return 0;
if (this.matchedSlabIndex < 0) return 0;
const normalRate = this.interestRateChartData[this.matchedSlabIndex]?.annualInterestRate ?? 0;
return this.selectedTenure.appliedRate - normalRate;
}
/** Charges Displayed Columns */
chargesDisplayedColumns: string[] = [
'name',
@@ -75,6 +75,7 @@
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[submittedOnDate]="submittedOnDate"
[cbsBaseRates]="cbsBaseRates"
(tenureSelected)="handleTenureSelected($event)"
>
</mifosx-recurring-deposits-account-interest-rate-chart-step>
@@ -105,6 +106,8 @@
[rdMaturityAmount]="rdMaturityAmount"
[rdInterestEarned]="rdInterestEarned"
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
(submitEvent)="submit()"
>
</mifosx-recurring-deposits-account-preview-step>
@@ -77,10 +77,14 @@ export class CreateRecurringDepositsAccountComponent {
recurringDepositsAccountTemplate: any;
/** Recurring Deposit Account Product Template */
recurringDepositsAccountProductTemplate: any;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
selectedTenureValue: number | null = null;
selectedTenureFrequencyId: number | null = null;
selectedDepositAmount: number | null = null;
rdAppliedRate: number | null = null;
rdIsSeniorCitizen = false;
constructor() {
this.route.data.subscribe((data: { recurringDepositsAccountTemplate: any }) => {
@@ -94,6 +98,10 @@ export class CreateRecurringDepositsAccountComponent {
*/
setTemplate($event: any) {
this.recurringDepositsAccountProductTemplate = $event;
this.productsService.getFdBaseInterestRates().subscribe({
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
error: () => (this.cbsBaseRates = [])
});
}
/** Captures tenure selection from calculator */
@@ -101,6 +109,8 @@ export class CreateRecurringDepositsAccountComponent {
this.selectedTenureValue = $event.depositPeriod;
this.selectedTenureFrequencyId = $event.depositPeriodFrequencyId;
this.selectedDepositAmount = $event.depositAmount;
this.rdAppliedRate = $event.appliedRate ?? null;
this.rdIsSeniorCitizen = $event.isSeniorCitizen ?? false;
}
/** Get Recurring Deposit Account Details Form Data */
@@ -283,6 +293,13 @@ export class CreateRecurringDepositsAccountComponent {
recurringDepositAccount.mandatoryRecommendedDepositAmount = this.selectedDepositAmount;
}
// Senior citizen rate override
if (this.rdIsSeniorCitizen && this.rdAppliedRate != null && this.rdAppliedRate > 0) {
recurringDepositAccount.isSeniorCitizen = true;
recurringDepositAccount.seniorCitizenInterestRate = this.rdAppliedRate;
recurringDepositAccount.nominalAnnualInterestRate = this.rdAppliedRate;
}
const nominees = this.recurringDepositsAccountCurrencyStep?.nominees ?? [];
if (nominees.length > 0) {
recurringDepositAccount.nominees = nominees.map((n: any) => ({
@@ -365,6 +365,7 @@
color="primary"
type="button"
class="soa-add-btn"
[disabled]="totalJointSharePercentage >= 100"
(click)="openAddJointHolderDialog()"
>
<mat-icon>person_add</mat-icon> Add Holder
@@ -434,11 +434,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
...(this.primaryClientId ? [this.primaryClientId] : []),
...this.jointHolders.map((h: any) => h.clientId).filter(Boolean)
];
const maxSharePercentage = Math.max(0, 100 - this.totalJointSharePercentage);
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true,
data: { excludedClientIds }
data: { excludedClientIds, maxSharePercentage }
});
ref.afterClosed().subscribe((result) => {
if (result) {
@@ -103,6 +103,18 @@
</div>
</div>
<!-- Senior Citizen -->
<div class="fdrc-calc-field fdrc-calc-field--senior">
<label class="fdrc-senior-label">
<input type="checkbox" [(ngModel)]="isSeniorCitizen" (change)="onSeniorCitizenChange()" />
<span class="fdrc-senior-text">
<i class="fas fa-user-shield"></i>
Senior Citizen
</span>
<span class="fdrc-senior-hint">Higher rate applied if available</span>
</label>
</div>
<div class="fdrc-calc-actions">
<button type="button" class="fdrc-calc-btn" (click)="calculateRate()">
<i class="fas fa-search"></i> Find Rate
@@ -126,34 +138,80 @@
<!-- Match Result -->
@if (matchResult) {
<div class="fdrc-result-card">
<div class="fdrc-result-card__check">
<i class="fas fa-check-circle"></i>
</div>
<div class="fdrc-result-card__body">
<!-- Selected Tenure Banner -->
<!-- Tenure Banner -->
<div class="fdrc-tenure-banner">
<i class="fas fa-calendar-check"></i>
<div class="fdrc-tenure-banner__content">
<span class="fdrc-tenure-banner__label">Your Selected RD Tenure</span>
<span class="fdrc-tenure-banner__value">{{ tenureValue }} {{ tenurePeriodUnit }}</span>
</div>
<span class="fdrc-tenure-banner__saved"> <i class="fas fa-save"></i> Will be saved </span>
<span class="fdrc-tenure-banner__saved"><i class="fas fa-save"></i> Will be saved</span>
</div>
<div class="fdrc-result-rate">
<span class="fdrc-result-rate__value">{{ matchResult.appliedRate | number: '1.2-2' }}%</span>
<span class="fdrc-result-rate__label">Annual Interest Rate</span>
</div>
<!-- Senior Citizen Status — always shown when checked -->
@if (isSeniorCitizen) {
@if ((matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<div class="fdrc-sc-status fdrc-sc-status--applied">
<i class="fas fa-user-shield fdrc-sc-status__icon"></i>
<span class="fdrc-sc-status__text">Senior Citizen Rate Applied</span>
<span class="fdrc-sc-status__bonus">
+{{
(matchResult.slab.seniorCitizenRate ?? 0) - (matchResult.slab.annualInterestRate ?? 0)
| number: '1.2-2'
}}% bonus
</span>
</div>
} @else {
<div class="fdrc-sc-status fdrc-sc-status--notice">
<i class="fas fa-info-circle fdrc-sc-status__icon"></i>
<span class="fdrc-sc-status__text"
>Senior Citizen selected — no additional rate configured for this slab, normal rate applied</span
>
</div>
}
}
<!-- Rate Display -->
@if (isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<!-- Before / After comparison -->
<div class="fdrc-rate-compare">
<div class="fdrc-rate-compare__block">
<span class="fdrc-rate-compare__tag">Normal Rate</span>
<span class="fdrc-rate-compare__val fdrc-rate-compare__val--strike"
>{{ matchResult.slab.annualInterestRate | number: '1.2-2' }}%</span
>
<span class="fdrc-rate-compare__sub">p.a.</span>
</div>
<div class="fdrc-rate-compare__arrow"><i class="fas fa-long-arrow-alt-right"></i></div>
<div class="fdrc-rate-compare__block fdrc-rate-compare__block--sc">
<span class="fdrc-rate-compare__tag fdrc-rate-compare__tag--sc"
><i class="fas fa-user-shield"></i> Sr. Citizen Rate</span
>
<span class="fdrc-rate-compare__val fdrc-rate-compare__val--sc"
>{{ matchResult.appliedRate | number: '1.2-2' }}%</span
>
<span class="fdrc-rate-compare__sub">p.a.</span>
</div>
</div>
} @else {
<div class="fdrc-result-rate">
<span class="fdrc-result-rate__value">{{ matchResult.appliedRate | number: '1.2-2' }}%</span>
<span class="fdrc-result-rate__label">Annual Interest Rate p.a.</span>
</div>
}
<!-- Slab Info -->
<div class="fdrc-result-meta">
<div class="fdrc-result-meta__item">
<i class="fas fa-layer-group"></i>
<span
>Applicable Slab:
<strong>
{{ matchResult.slab.fromPeriod }}–{{ matchResult.slab.toPeriod ?? '∞' }}
{{ matchResult.slab.periodType?.value }}
</strong>
</span>
<strong
>{{ matchResult.slab.fromPeriod }}–{{ matchResult.slab.toPeriod ?? '∞' }}
{{ matchResult.slab.periodType?.value }}</strong
></span
>
</div>
@if (matchResult.slab.description) {
<div class="fdrc-result-meta__item">
@@ -164,11 +222,14 @@
</div>
<!-- Maturity Summary -->
@if (matchResult.maturityAmount !== null) {
@if (matchResult.maturityAmount) {
<div class="fdrc-maturity-card">
<div class="fdrc-maturity-card__header">
<i class="fas fa-coins"></i>
<span>RD Maturity Summary (Estimate)</span>
@if (isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<span class="fdrc-sc-tag"><i class="fas fa-user-shield"></i> Sr. Citizen Rate</span>
}
</div>
<div class="fdrc-maturity-card__body">
<div class="fdrc-maturity-row">
@@ -179,8 +240,17 @@
<span class="fdrc-maturity-row__label">Tenure</span>
<span class="fdrc-maturity-row__value">{{ tenureValue }} {{ tenurePeriodUnit }}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Rate</span>
<div
class="fdrc-maturity-row"
[class.fdrc-maturity-row--sc]="isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0"
>
<span class="fdrc-maturity-row__label">
@if (isSeniorCitizen && (matchResult.slab.seniorCitizenRate ?? 0) > 0) {
<i class="fas fa-user-shield"></i> Sr. Citizen Rate
} @else {
Interest Rate
}
</span>
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
@@ -189,9 +259,9 @@
</div>
<div class="fdrc-maturity-row fdrc-maturity-row--total">
<span class="fdrc-maturity-row__label">Estimated Maturity Amount</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big"
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
>
</div>
</div>
</div>
@@ -263,6 +333,22 @@
</td>
</ng-container>
<!-- Senior Citizen Rate Column -->
<ng-container matColumnDef="seniorCitizenRate">
<th mat-header-cell *matHeaderCellDef class="fdrc-th">
<i class="fas fa-user-shield" style="margin-right: 4px; color: #6366f1"></i>Sr. Citizen Rate
</th>
<td mat-cell *matCellDef="let slab" class="fdrc-td">
@if (slab.seniorCitizenRate > 0) {
<span class="fdrc-rate-badge fdrc-rate-badge--senior"
>{{ slab.seniorCitizenRate | number: '1.2-2' }}%</span
>
} @else {
<span class="fdrc-td--na">—</span>
}
</td>
</ng-container>
<!-- Description Column -->
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef class="fdrc-th">Description</th>
@@ -337,8 +337,7 @@ $red-light: #fee2e2;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
margin-top: 22px;
flex: 0 0 100%;
}
.fdrc-calc-btn {
@@ -465,25 +464,157 @@ $red-light: #fee2e2;
// ── Result Card ───────────────────────────────────────────────────────────────
.fdrc-result-card {
display: flex;
align-items: flex-start;
gap: 16px;
margin-top: 16px;
padding: 18px 20px;
background: $green-light;
border: 1.5px solid rgba($green, 0.35);
border-radius: 12px;
&__check {
font-size: 26px;
color: $green;
flex-shrink: 0;
margin-top: 2px;
&__body {
display: flex;
flex-direction: column;
gap: 12px;
}
}
// ── Senior Citizen Status Banner ──────────────────────────────────────────
.fdrc-sc-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
&--applied {
background: #ede9fe;
border: 1.5px solid #a78bfa;
color: #4c1d95;
}
&__body {
&--notice {
background: #fef3c7;
border: 1.5px solid #fcd34d;
color: #78350f;
}
&__icon {
font-size: 16px;
flex-shrink: 0;
}
&__text {
flex: 1;
min-width: 0;
}
&__bonus {
margin-left: auto;
flex-shrink: 0;
background: #7c3aed;
color: #fff;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
}
// ── Rate Comparison Block (Normal → Senior) ───────────────────────────────
.fdrc-rate-compare {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
&__block {
display: flex;
flex-direction: column;
gap: 3px;
&--sc {
// senior block styling via child classes
}
}
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: $gray-200;
color: $gray-600;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
width: fit-content;
&--sc {
background: #ede9fe;
color: #5b21b6;
}
}
&__val {
font-size: 30px;
font-weight: 800;
line-height: 1;
&--strike {
color: $gray-600;
text-decoration: line-through;
}
&--sc {
color: #5b21b6;
}
}
&__sub {
font-size: 11px;
color: $gray-600;
}
&__arrow {
font-size: 22px;
color: $gray-600;
padding-bottom: 8px;
flex-shrink: 0;
}
}
// ── SC Tag (maturity card header) ─────────────────────────────────────────
.fdrc-sc-tag {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
padding: 3px 10px;
background: #ede9fe;
color: #5b21b6;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
// ── SC Maturity Row ───────────────────────────────────────────────────────
.fdrc-maturity-row--sc {
.fdrc-maturity-row__label {
color: #5b21b6;
font-weight: 600;
}
.fdrc-maturity-row__value {
color: #5b21b6;
font-weight: 700;
}
}
@@ -730,6 +861,151 @@ $red-light: #fee2e2;
font-weight: 700;
}
.fdrc-rate-badge--senior {
background: #ede9fe;
color: #5b21b6;
}
// ── Senior Citizen Rate Comparison View ───────────────────────────────────
.fdrc-senior-rate-view {
margin-bottom: 12px;
&__row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
flex-wrap: wrap;
}
&__normal {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
opacity: 0.65;
}
&__senior {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: $gray-200;
color: $gray-600;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
&--sr {
background: #ede9fe;
color: #5b21b6;
}
}
&__strike {
font-size: 24px;
font-weight: 700;
color: $gray-600;
text-decoration: line-through;
line-height: 1;
}
&__applied {
font-size: 32px;
font-weight: 800;
color: #5b21b6;
line-height: 1;
}
&__arrow {
font-size: 18px;
color: $gray-600;
flex-shrink: 0;
align-self: center;
margin-top: 16px;
}
&__bonus {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
background: #ede9fe;
color: #5b21b6;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
i {
font-size: 13px;
}
}
}
.fdrc-senior-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 8px;
background: #ede9fe;
color: #5b21b6;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.fdrc-calc-field--senior {
flex: 0 0 100%;
display: flex;
align-items: center;
}
.fdrc-senior-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #6366f1;
cursor: pointer;
}
}
.fdrc-senior-text {
display: inline-flex;
align-items: center;
gap: 5px;
font-weight: 600;
color: #4338ca;
font-size: 13px;
i {
color: #6366f1;
}
}
.fdrc-senior-hint {
font-size: 11px;
color: #94a3b8;
}
.fdrc-incentive-btn {
display: inline-flex;
align-items: center;
@@ -84,6 +84,8 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
@Input() recurringDepositsAccountTemplate: any;
@Input() recurringDepositsAccountProductTemplate: any;
@Input() submittedOnDate: Date | null = null;
/** Active CBS base interest rate versions — when provided, overrides the product chart slabs */
@Input() cbsBaseRates: any[] = [];
@Output() tenureSelected = new EventEmitter<{
depositPeriod: number;
@@ -92,9 +94,15 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
maturityAmount?: number;
interestEarned?: number;
daysInYear?: number;
appliedRate?: number;
isSeniorCitizen?: boolean;
}>();
get interestRateChartData(): any[] {
const activeBase = (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
if (activeBase?.slabs?.length) {
return this.normalizeCbsSlabs(activeBase.slabs);
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (!template) return [];
return (
@@ -107,13 +115,23 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
);
}
private normalizeCbsSlabs(slabs: any[]): any[] {
return (slabs || []).map((s) => ({
...s,
periodType: { id: s.periodTypeEnum, value: s.periodTypeLabel }
}));
}
chartSlabsDisplayedColumns: string[] = [
'period',
'amountRange',
'interest',
'seniorCitizenRate',
'description',
'actions'
];
isSeniorCitizen = false;
incentivesDisplayedColumns: string[] = [
'entityType',
'attributeName',
@@ -208,7 +226,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return;
}
const rate = matchedSlab.annualInterestRate ?? 0;
const rate =
this.isSeniorCitizen && (matchedSlab.seniorCitizenRate ?? 0) > 0
? (matchedSlab.seniorCitizenRate ?? 0)
: (matchedSlab.annualInterestRate ?? 0);
const result: SlabMatchResult = {
slab: matchedSlab,
slabIndex: matchedIndex,
@@ -271,7 +292,9 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
depositAmount: this.principalAmount || undefined,
maturityAmount: this.matchResult.maturityAmount,
interestEarned: this.matchResult.interestEarned,
daysInYear: this.matchResult.daysInYear
daysInYear: this.matchResult.daysInYear,
appliedRate: this.matchResult.appliedRate,
isSeniorCitizen: this.isSeniorCitizen
});
}
}
@@ -298,6 +321,14 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
this.validationWarning = null;
}
onSeniorCitizenChange() {
if (this.tenureValue && this.tenureValue > 0) {
this.calculateRate();
} else {
this.matchResult = null;
}
}
isMatchedSlab(index: number): boolean {
return this.matchResult?.slabIndex === index;
}
@@ -609,8 +609,16 @@
>
</div>
<div class="preview-field">
<span class="preview-label">Applicable Interest Rate</span>
<span class="preview-value">{{ rdApplicableSlab?.annualInterestRate | formatNumber }}%</span>
<span class="preview-label">
@if (rdIsSeniorCitizen && rdSeniorCitizenBonus > 0) {
<i class="fas fa-user-shield" style="color: #5b21b6; margin-right: 4px"></i>Sr. Citizen Rate
} @else {
Applicable Interest Rate
}
</span>
<span class="preview-value" [style.color]="rdIsSeniorCitizen && rdSeniorCitizenBonus > 0 ? '#5b21b6' : ''">
{{ rdAppliedInterestRate | formatNumber }}%
</span>
</div>
<div class="preview-field">
<span class="preview-label">Tenure</span>
@@ -652,7 +660,26 @@
<div class="preview-body">
<!-- Selected Tenure + Rate Banner -->
@if (rdTenureDays && matchedSlabIndex >= 0) {
<div class="preview-tenure-banner">
<!-- Senior Citizen Status Banner -->
@if (rdIsSeniorCitizen) {
@if (rdSeniorCitizenBonus > 0) {
<div class="preview-sc-status preview-sc-status--applied">
<i class="fas fa-user-shield"></i>
<span>Senior Citizen Rate Applied</span>
<span class="preview-sc-status__bonus">+{{ rdSeniorCitizenBonus | formatNumber }}% bonus</span>
</div>
} @else {
<div class="preview-sc-status preview-sc-status--notice">
<i class="fas fa-info-circle"></i>
<span>Senior Citizen selected — no additional rate configured for this slab</span>
</div>
}
}
<div
class="preview-tenure-banner"
[class.preview-tenure-banner--sc]="rdIsSeniorCitizen && rdSeniorCitizenBonus > 0"
>
<div class="preview-tenure-banner__left">
<i class="fas fa-calendar-check"></i>
<div>
@@ -662,9 +689,17 @@
</div>
<div class="preview-tenure-banner__divider"></div>
<div class="preview-tenure-banner__right">
<span class="preview-tenure-banner__rate-label">Applicable Interest Rate</span>
<span class="preview-tenure-banner__rate"
>{{ interestRateChartData[matchedSlabIndex]?.annualInterestRate | formatNumber }}%</span
<span class="preview-tenure-banner__rate-label">
@if (rdIsSeniorCitizen && rdSeniorCitizenBonus > 0) {
<i class="fas fa-user-shield" style="color: #5b21b6; margin-right: 4px"></i>Sr. Citizen Rate
} @else {
Applicable Interest Rate
}
</span>
<span
class="preview-tenure-banner__rate"
[style.color]="rdIsSeniorCitizen && rdSeniorCitizenBonus > 0 ? '#5b21b6' : ''"
>{{ rdAppliedInterestRate | formatNumber }}%</span
>
</div>
<div class="preview-tenure-banner__slab">
@@ -283,6 +283,54 @@
border-left: 3px solid #16a34a;
}
// ── Senior Citizen Status Banner (preview) ────────────────────────────────────
.preview-sc-status {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
&--applied {
background: #ede9fe;
border: 1.5px solid #a78bfa;
color: #4c1d95;
}
&--notice {
background: #fef3c7;
border: 1.5px solid #fcd34d;
color: #78350f;
}
&__bonus {
margin-left: auto;
flex-shrink: 0;
background: #7c3aed;
color: #fff;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
}
.preview-tenure-banner--sc {
border-color: #a78bfa;
background: #f5f3ff;
.preview-tenure-banner__left > i {
color: #5b21b6;
}
.preview-tenure-banner__divider {
background: #a78bfa;
}
}
// ── Legend below table ────────────────────────────────────────────────────────
.preview-slab-legend {
display: flex;
@@ -80,6 +80,19 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
@Input() rdMaturityAmount: number | null = null;
@Input() rdInterestEarned: number | null = null;
@Input() rdDaysInYear: number | null = null;
@Input() rdAppliedRate: number | null = null;
@Input() rdIsSeniorCitizen = false;
get rdAppliedInterestRate(): number {
if (this.rdAppliedRate != null) return this.rdAppliedRate;
return this.rdApplicableSlab?.annualInterestRate ?? 0;
}
get rdSeniorCitizenBonus(): number {
if (!this.rdIsSeniorCitizen || this.rdAppliedRate == null) return 0;
const normalRate = this.rdApplicableSlab?.annualInterestRate ?? 0;
return this.rdAppliedRate - normalRate;
}
/** Output the submit action */
@Output() submitEvent = new EventEmitter();
@@ -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;
}
});
}
}
@@ -226,6 +226,7 @@
color="primary"
type="button"
class="soa-add-btn"
[disabled]="totalJointSharePercentage >= 100"
(click)="openAddJointHolderDialog()"
>
<mat-icon>person_add</mat-icon> Add Holder
@@ -334,11 +334,12 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
...(this.primaryClientId ? [this.primaryClientId] : []),
...this.jointHolders.map((h: any) => h.clientId).filter(Boolean)
];
const maxSharePercentage = Math.max(0, 100 - this.totalJointSharePercentage);
const ref = this.dialog.open(AddRelatedClientDialogComponent, {
width: '500px',
panelClass: 'arc-dialog-panel',
disableClose: true,
data: { excludedClientIds }
data: { excludedClientIds, maxSharePercentage }
});
ref.afterClosed().subscribe((result) => {
if (result) {
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260520',
'hash': 'bf3e26a0'
'version': '260521',
'hash': '846b2484'
},
'allow_switching_backend_instance': true
};