Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s

This commit is contained in:
2026-08-25 11:21:13 +05:30
87 changed files with 4921 additions and 347 deletions
@@ -2590,28 +2590,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
dormantFlag: 'ACTIVE'
};
}
/**
* Automatically capitalizes the first letter of each word and lowercases the rest.
* e.g., "sri rAm" -> "Sri Ram"
*/
formatName(controlName: string): void {
const control = this.createClientForm.get(controlName);
if (!control || typeof control.value !== 'string') return;
const val = control.value;
const formatted = val
.split(' ')
.map((word: string) => {
if (!word) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join(' ');
if (formatted !== val) {
control.setValue(formatted, { emitEvent: false });
}
}
}
/**
@@ -435,12 +435,17 @@
@if (!showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isFixed'">
<table mat-table [dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'open' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -524,12 +529,20 @@
<!-- Closed Fixed Deposit Accounts -->
@if (showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isFixed'">
<table
mat-table
[dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'closed' : 'isFixed'"
>
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -209,6 +209,20 @@ $red: #d93025;
gap: 0;
}
// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number
// rather than this term's own account number.
.fd-renewal-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background-color: $gray-200;
color: $gray-700;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
// ── Table Wrapper ──
.table-wrapper {
border-radius: 0 0 8px 8px;
@@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy {
}
get fixedDepositCount(): number {
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length;
return this.fixedDepositAccountsForDisplay.filter(
(a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit'
).length;
}
/**
* Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away.
*
* An FD renewal closes one account and opens the next term as its own account row, so a renewed FD
* would otherwise appear twice on this page -- the matured term under Closed and the new term under
* Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later
* cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its
* current term. A chain that has genuinely ended keeps its final term, so it still lists as closed.
*
* Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched.
*/
get fixedDepositAccountsForDisplay(): any[] {
// Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so
// returning a freshly built array on every change-detection pass would make the table re-bind and
// re-render its rows on every tick -- mouse moves included.
if (this.fdDisplayCacheSource !== this.savingAccounts) {
this.fdDisplayCacheSource = this.savingAccounts;
this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts);
}
return this.fdDisplayCache;
}
private fdDisplayCacheSource: any[] = null;
private fdDisplayCache: any[] = [];
private foldSupersededFdCycles(accounts: any[]): any[] {
const latestCycleByFd = new Map<string, number>();
for (const account of accounts) {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
continue;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) {
latestCycleByFd.set(fdNumber, cycle);
}
}
return accounts.filter((account: any) => {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
return true;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
return cycle === latestCycleByFd.get(fdNumber);
});
}
get recurringDepositCount(): number {
@@ -12,9 +12,7 @@
<fa-icon icon="university" class="cca-header__icon"></fa-icon>
<div>
<h2 class="cca-header__title">Create Current Account</h2>
<div class="cca-header__sub">
Pick a product, confirm currency, set digital channels and review before submit.
</div>
<div class="cca-header__sub">Pick a product, confirm currency and review before submit.</div>
</div>
</div>
@@ -78,34 +76,7 @@
</div>
</mat-tab>
<!-- Tab 3: Digital -->
<mat-tab [disabled]="!currentAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="mobile-alt" class="cca-tab__icon"></fa-icon>
Digital
</ng-template>
<div class="cca-tab-body">
@if (currentAccountProductTemplate) {
<mifosx-current-account-digital-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
></mifosx-current-account-digital-step>
}
<div class="cca-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cca-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 3: Preview -->
<mat-tab [disabled]="!currentAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cca-tab__icon"></fa-icon>
@@ -57,8 +57,8 @@ export class CreateCurrentAccountComponent {
currentAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
/** Total wizard tabs — Product, Currency, Preview. */
private readonly totalTabs = 3;
@ViewChild(CurrentAccountDetailsStepComponent, { static: true })
currentAccountDetailsStep: CurrentAccountDetailsStepComponent;
@@ -30,18 +30,6 @@
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-fill">
<mat-label>Opening Balance</mat-label>
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
<span matPrefix>₹&nbsp;</span>
@if (minRequiredOpeningBalance) {
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
}
@if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) {
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
@@ -36,8 +36,6 @@ export class ActivateCurrentAccountComponent implements OnInit {
private router = inject(Router);
private settingsService = inject(SettingsService);
@Input() minRequiredOpeningBalance: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -71,18 +69,10 @@ export class ActivateCurrentAccountComponent implements OnInit {
* Creates the activate current account form.
*/
createActivateCurrentAccountForm() {
const minBalance = this.minRequiredOpeningBalance ?? 0;
this.activateCurrentAccountForm = this.formBuilder.group({
activatedOnDate: [
'',
Validators.required
],
openingBalance: [
minBalance,
[
Validators.required,
Validators.min(minBalance)
]
]
});
}
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveCurrentAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveCurrentAccountComponent implements OnInit {
}
/**
* Submits the form and approves the current account,
* if successful redirects to the current account.
* Submits the form, approves the current account, then immediately activates it on the same
* date -- Approve and Activate are still two separate Fineract commands underneath (each shows
* up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveCurrentAccountFormData = this.approveCurrentAccountForm.value;
@@ -98,7 +99,16 @@ export class ApproveCurrentAccountComponent implements OnInit {
locale
};
this.currentAccountsService.executeCurrentAccountCommand(this.accountId, 'approve', data).subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveCurrentAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.currentAccountsService
.executeCurrentAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
});
}
}
@@ -13,9 +13,7 @@
<mifosx-reject-current-account></mifosx-reject-current-account>
}
@if (actions['Activate']) {
<mifosx-activate-current-account
[minRequiredOpeningBalance]="accountData?.minRequiredOpeningBalance"
></mifosx-activate-current-account>
<mifosx-activate-current-account></mifosx-activate-current-account>
}
@if (actions['Undo Approval']) {
<mifosx-undo-approval-current-account></mifosx-undo-approval-current-account>
@@ -15,7 +15,6 @@
<h3 class="dig-header__title">Digital &amp; Account Features</h3>
<p class="dig-header__sub">Toggle the facilities available for this account (as per RBI guidelines)</p>
</div>
<span class="dig-header__badge">FROM PRODUCT</span>
</div>
<div class="dig-grid">
@@ -52,12 +52,11 @@ export class CurrentAccountDigitalStepComponent {
}
}
// Note: a new account always starts with every digital facility off, even if the product has
// its own defaults configured -- those are enabled deliberately, one at a time, after the
// account is created and active, not inherited automatically at opening.
@Input()
set currentAccountProductTemplate(value: any) {
if (!this.hasExistingAccount && value?.accountFacilityConfig) {
this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig };
}
}
currentAccountProductTemplate: any;
get currentAccountDigital() {
return { accountFacilityConfig: { ...this.facilityConfig } };
@@ -136,12 +136,11 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {};
}
/** Digital facility config as actually captured by a digital step in this wizard -- no fallback
* to the product's own defaults, since account creation no longer has a digital step at all and
* must not preview facilities the officer never actually chose. */
get digitalFacilityConfig(): any {
return (
this.currentAccountDigital?.accountFacilityConfig ??
this.currentAccountProductTemplate?.accountFacilityConfig ??
null
);
return this.currentAccountDigital?.accountFacilityConfig ?? null;
}
get hasDigitalSection(): boolean {
@@ -33,6 +33,7 @@
<mifosx-fixed-deposit-account-details-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
[hideButtons]="true"
>
</mifosx-fixed-deposit-account-details-step>
@@ -107,7 +108,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cfa-tab-body">
@if (fixedDepositsAccountProductTemplate) {
<mifosx-fixed-deposit-account-maturity-terms-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-fixed-deposit-account-maturity-terms-step>
}
<div class="cfa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cfa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
@@ -121,6 +155,7 @@
[fixedDepositAccountData]="fixedDepositAccount"
[selectedTenure]="selectedTenure"
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
></mifosx-fixed-deposit-account-preview-step>
@@ -24,6 +24,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FixedDepositAccountDetailsStepComponent,
FixedDepositAccountInterestRateChartStepComponent,
FixedDepositAccountCurrencyStepComponent,
FixedDepositAccountMaturityTermsStepComponent,
FixedDepositAccountPreviewStepComponent
]
})
@@ -56,6 +58,10 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@ViewChild(FixedDepositAccountCurrencyStepComponent)
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
@ViewChild(FixedDepositAccountPreviewStepComponent)
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
/** Fixed Deposits Account Template */
fixedDepositsAccountTemplate: any;
@@ -63,8 +69,8 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Chart, Preview. */
private readonly totalTabs = 4;
/** Total wizard tabs — Product, Currency, Chart, Terms, Preview. */
private readonly totalTabs = 5;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
@@ -243,6 +249,7 @@ export class CreateFixedDepositAccountComponent {
...this.fixedDepositAccount,
...terms,
...settings,
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
...(this.selectedTenure
? {
depositPeriod: this.selectedTenure.depositPeriod,
@@ -35,6 +35,7 @@
<mifosx-fixed-deposit-account-details-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
[hideButtons]="true"
>
</mifosx-fixed-deposit-account-details-step>
@@ -111,7 +112,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cfa-tab-body">
@if (fixedDepositsAccountProductTemplate) {
<mifosx-fixed-deposit-account-maturity-terms-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-fixed-deposit-account-maturity-terms-step>
}
<div class="cfa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cfa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
@@ -125,6 +159,7 @@
[fixedDepositAccountData]="fixedDepositAccount"
[selectedTenure]="selectedTenure"
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
></mifosx-fixed-deposit-account-preview-step>
@@ -25,6 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FixedDepositAccountDetailsStepComponent,
FixedDepositAccountInterestRateChartStepComponent,
FixedDepositAccountCurrencyStepComponent,
FixedDepositAccountMaturityTermsStepComponent,
FixedDepositAccountPreviewStepComponent
]
})
@@ -58,6 +60,10 @@ export class EditFixedDepositAccountComponent {
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@ViewChild(FixedDepositAccountCurrencyStepComponent)
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
@ViewChild(FixedDepositAccountPreviewStepComponent)
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
/** Full account data + template from resolver */
fixedDepositsAccountAndTemplate: any;
@@ -65,7 +71,7 @@ export class EditFixedDepositAccountComponent {
fixedDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
@@ -236,6 +242,7 @@ export class EditFixedDepositAccountComponent {
...this.fixedDepositAccount,
...terms,
...settings,
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
...(this.selectedTenure
? {
depositPeriod: this.selectedTenure.depositPeriod,
@@ -7,7 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
OnInit,
OnChanges,
SimpleChanges,
Input,
Output,
EventEmitter,
inject,
ChangeDetectorRef
} from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
@@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
IntroducerSearchComponent
]
})
export class FixedDepositAccountDetailsStepComponent implements OnInit {
export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
@@ -56,6 +66,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
/** Fixed Deposits Account Template */
@Input() fixedDepositsAccountTemplate: any;
@Input() hideButtons = false;
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
@Input() externalLinkedAccountId: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -222,6 +235,23 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
this.dalSearch = '';
}
ngOnChanges(changes: SimpleChanges): void {
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
return;
}
const id = this.externalLinkedAccountId;
if (!id) return;
const savings = this.dalAllSavings.find((a: any) => a.id === id);
const current = this.dalAllCurrent.find((a: any) => a.id === id);
if (savings) {
this.dalSelectedType = 'SAVINGS';
this.selectedLinkedAccount = savings;
} else if (current) {
this.dalSelectedType = 'CURRENT';
this.selectedLinkedAccount = current;
}
}
private loadClientAccounts(): void {
const clientId = String(this.fixedDepositsAccountTemplate?.clientId);
if (!clientId) return;
@@ -0,0 +1,108 @@
<!--
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/.
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
<div class="dal-card flex-98">
<div class="dal-card__header">
<div class="dal-card__header-left">
<div class="dal-card__icon">
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
<div class="dal-card__body">
@if (selectedTransferAccount) {
<div class="dal-selected">
<div class="dal-selected__info">
<span class="dal-type-badge dal-type-badge--savings">
<mat-icon>savings</mat-icon>
Savings
</span>
<div class="dal-selected__details">
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
</div>
@if (selectedTransferAccount.summary?.accountBalance != null) {
<div class="dal-selected__balance">
<span class="dal-selected__bal-label">Balance</span>
<span class="dal-selected__bal-amount">
{{ selectedTransferAccount.currency?.code }}
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
</span>
</div>
}
</div>
<button
mat-stroked-button
color="primary"
type="button"
class="dal-change-btn"
(click)="clearTransferAccount()"
>
<mat-icon>swap_horiz</mat-icon>
Change
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
({{ account.accountNo }}) {{ account.savingsProductName }}
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
.dal-card {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
&__header-left {
display: flex;
align-items: center;
gap: 14px;
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #0ea5e9;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
&__sub {
display: block;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
&__body {
padding: 20px 24px;
}
}
.dal-selected {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: 2px solid #0ea5e9;
border-radius: 12px;
background: #f0f9ff;
&__info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
flex-wrap: wrap;
}
&__details {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
&__accno {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
font-family: monospace;
}
&__product {
font-size: 12px;
color: #64748b;
}
&__balance {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
&__bal-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__bal-amount {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
}
.dal-change-btn {
white-space: nowrap;
}
.dal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--savings {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
mat-icon {
color: #2563eb;
}
}
}
@@ -0,0 +1,117 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Fixed Deposit Account Maturity Terms Step -- what happens to this account at maturity
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
* per account rather than silently inherited from the product.
*/
@Component({
selector: 'mifosx-fixed-deposit-account-maturity-terms-step',
templateUrl: './fixed-deposit-account-maturity-terms-step.component.html',
styleUrls: ['./fixed-deposit-account-maturity-terms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
@Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any;
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
@Input() linkedSavingsAccountId: number | null = null;
closureSettingsForm: UntypedFormGroup;
maturityInstructionOptions: any[] = [];
savingsAccountsData: any[] = [];
preClosurePenalInterestOnTypeData: any[] = [];
constructor() {
this.closureSettingsForm = this.formBuilder.group({
maturityInstructionId: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
);
} else {
this.closureSettingsForm.removeControl('transferToSavingsId');
}
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.linkedSavingsAccountId) {
const control = this.closureSettingsForm.get('transferToSavingsId');
if (control && !control.value && this.linkedSavingsAccountId) {
control.setValue(this.linkedSavingsAccountId);
}
}
if (this.fixedDepositsAccountProductTemplate) {
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
// the account matures, before the customer has actually collected it -- disabled everywhere.
this.maturityInstructionOptions = (
this.fixedDepositsAccountProductTemplate.maturityInstructionOptions ?? []
).filter((option: any) => option.id !== 100);
this.savingsAccountsData = this.fixedDepositsAccountProductTemplate.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData =
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: this.fixedDepositsAccountProductTemplate.preClosurePenalApplicable ?? false,
preClosurePenalInterest: this.fixedDepositsAccountProductTemplate.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId:
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnType?.id ?? ''
});
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
}
}
return settings;
}
}
@@ -683,6 +683,41 @@
</div>
</div>
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-flag-checkered preview-section__icon"></i>
<span class="preview-section__title">Maturity &amp; Pre-mature Closure</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
</div>
@if (closureSettings?.transferToSavingsId) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
</div>
}
</div>
</div>
</div>
@if (!hideButtons) {
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
@@ -164,7 +164,26 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
/** Form submission event */
@Output() submitEvent = new EventEmitter();
constructor() {}
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
@Input() closureSettings: any = {};
get closureMaturityInstructionLabel(): string {
const options = this.fixedDepositsAccountProductTemplate?.maturityInstructionOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
return match?.value ?? '—';
}
get closureTransferToSavingsLabel(): string {
const accounts = this.fixedDepositsAccountProductTemplate?.savingsAccounts ?? [];
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
get closurePenalInterestOnTypeLabel(): string {
const options = this.fixedDepositsAccountProductTemplate?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—';
}
ngOnChanges() {
if (this.fixedDepositsAccountProductTemplate) {
@@ -0,0 +1,191 @@
<!--
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="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
</div>
</div>
<div mat-dialog-content class="pmt__content">
@if (loadingTemplate) {
<div class="pmt__loading">
<mat-spinner diameter="36"></mat-spinner>
</div>
} @else if (loadFailed) {
<div class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not load this account's payment options. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="paymentForm">
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pmt__option"
role="radio"
[attr.aria-checked]="paymentForm.get('onAccountClosureId').value === closureOption.id"
[class.pmt__option--active]="paymentForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pmt__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pmt__option-text">
<span class="pmt__option-title">{{ closureOption.value }}</span>
<span class="pmt__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (paymentForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pmt__option-check"></fa-icon>
}
</button>
}
</div>
@if (
paymentForm.controls.onAccountClosureId.hasError('required') &&
paymentForm.controls.onAccountClosureId.touched
) {
<div class="pmt__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (isPartialReinvest) {
<div class="pmt__split">
<mat-form-field appearance="outline" class="pmt__split-input">
<mat-label>{{ 'labels.inputs.Reinvest Amount' | translate }}</mat-label>
<input type="number" matInput required formControlName="reinvestAmount" />
@if (paymentForm.controls.reinvestAmount?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('min')) {
<mat-error>{{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }}</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) {
<mat-error>{{
'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate
}}</mat-error>
}
</mat-form-field>
<span class="pmt__split-arrow"><fa-icon icon="arrow-right"></fa-icon></span>
<div class="pmt__split-remainder">
<span class="pmt__split-remainder-label">{{ 'labels.inputs.Remainder to Transfer' | translate }}</span>
<span class="pmt__split-remainder-value">{{
remainderAmount !== null ? (remainderAmount | formatNumber) : '—'
}}</span>
</div>
</div>
@if (remainderIsApproximate) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
The selected date is before maturity. Interest up to that date is posted first, so the amount actually
transferred will be at least the figure shown.
</p>
}
}
@if (noDestinationAccounts) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that keeps
the funds in this deposit.
</p>
}
@if (paymentForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (paymentForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (paymentForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (submitError) {
<p class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
color="primary"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -0,0 +1,296 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
overflow: hidden;
margin: -24px;
}
.pmt__header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex: 0 0 auto;
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
overflow-y: auto;
flex: 1 1 auto;
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 18px;
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
.pmt__date {
width: 100%;
}
.pmt__section-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
}
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
line-height: 1.45;
padding: 10px 14px;
margin: 4px 0 8px;
}
.pmt__hint {
display: flex;
align-items: flex-start;
gap: 4px;
background: $soft;
border: 1px solid $border;
border-radius: 8px;
color: $slate;
font-size: 12px;
line-height: 1.45;
padding: 9px 13px;
margin: 0 0 10px;
}
.pmt__hint--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
flex-wrap: wrap;
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
display: flex;
flex-direction: column;
gap: 2px;
background: $green-soft;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 14px;
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-remainder-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $green;
}
.pmt__split-remainder-value {
font-size: 16px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
}
@@ -0,0 +1,288 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
*/
@Component({
selector: 'mifosx-payment-dialog',
templateUrl: './payment-dialog.component.html',
styleUrls: ['./payment-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
MatProgressSpinnerModule,
FaIconComponent,
FormatNumberPipe
]
})
export class PaymentDialogComponent implements OnInit {
/** The `onAccountClosureId` codes each closure type is registered under on the backend. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly REINVEST_FULL_ID = 300;
private static readonly REINVEST_PRINCIPAL_ID = 400;
private static readonly PARTIAL_REINVEST_ID = 500;
private static readonly ACTION_ICONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]:
'Renew only the principal; interest transferred to a savings account',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest'
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
loadingTemplate = true;
loadFailed = false;
submitting = false;
submitError: string = null;
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
],
onAccountClosureId: [
'',
Validators.required
],
note: ['']
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
this.paymentForm
.get('closedOnDate')
.valueChanges.subscribe(() =>
this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false })
);
this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({
next: (response: any) => {
// Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a
// staff-triggered Payment that may run well before or after the actual counter transaction --
// Transfer to Savings or a Reinvest option covers every real case instead.
this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter(
(option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID
);
this.savingsAccountsData = response.savingsAccounts ?? [];
this.maturityAmount = response.maturityAmount;
this.loadingTemplate = false;
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out
// and no indication of why the form never appeared.
error: () => {
this.loadingTemplate = false;
this.loadFailed = true;
}
});
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
* The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure
* matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the
* maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current
* balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept
* an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger.
*/
get reinvestCap(): number | null {
if (this.processingOnOrAfterMaturity) {
return this.maturityAmount ?? this.data.accountBalance ?? null;
}
return this.data.accountBalance ?? this.maturityAmount ?? null;
}
/** Whether the chosen closure date is on or after the deposit's maturity date. */
private get processingOnOrAfterMaturity(): boolean {
const chosen = this.paymentForm?.get('closedOnDate')?.value;
if (!chosen || !this.data.maturityDate) {
return false;
}
return this.toTime(chosen) >= this.toTime(this.data.maturityDate);
}
/** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */
private toTime(value: any): number {
if (value instanceof Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
/** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */
get remainderIsApproximate(): boolean {
return !this.processingOnOrAfterMaturity;
}
iconFor(id: number): string {
return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.paymentForm.get('onAccountClosureId').setValue(id);
this.paymentForm.get('onAccountClosureId').markAsTouched();
}
/** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
const needsDestinationAccount =
id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID ||
id === PaymentDialogComponent.PARTIAL_REINVEST_ID ||
id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID;
if (needsDestinationAccount) {
if (!this.paymentForm.contains('toSavingsAccountId')) {
this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.paymentForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.paymentForm.removeControl('toSavingsAccountId');
this.paymentForm.removeControl('transferDescription');
}
if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) {
if (!this.paymentForm.contains('reinvestAmount')) {
this.paymentForm.addControl(
'reinvestAmount',
new UntypedFormControl('', [
Validators.required,
Validators.min(0.01),
this.belowMaturityAmountValidator()
])
);
}
} else {
this.paymentForm.removeControl('reinvestAmount');
}
}
/** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */
private belowMaturityAmountValidator(): ValidatorFn {
return (control) => {
const cap = Number(this.reinvestCap);
if (!cap || control.value === null || control.value === '') {
return null;
}
return Number(control.value) >= cap ? { belowMaturityAmount: true } : null;
};
}
get isPartialReinvest(): boolean {
return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID;
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0
);
}
/** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */
get remainderAmount(): number | null {
const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value;
if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) {
return null;
}
return Number(this.reinvestCap) - Number(reinvestAmount);
}
submit(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This payment could not be processed. Please review the details and try again.';
}
});
}
}
@@ -37,7 +37,7 @@
<span class="fd-hero__meta-item">
<span class="fd-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ fixedDepositsAccountData.accountNo }}"
accountNo="{{ fixedDepositsAccountData.fdAccountNumber || fixedDepositsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
@@ -79,6 +79,16 @@
<!-- Action menu (kebab) -->
<div class="fd-hero__actions">
@if (!fixedDepositsAccountData.status.rejected && !fixedDepositsAccountData.status.submittedAndPendingApproval) {
<button
mat-icon-button
matTooltip="Print Certificate"
(click)="printCertificate()"
aria-label="Print certificate"
>
<fa-icon icon="print"></fa-icon>
</button>
}
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
@@ -220,6 +230,19 @@
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
}
@if (fixedDepositsAccountData.renewalHistory?.length) {
<a
mat-tab-link
class="fd-tab"
[routerLink]="['./renewal-history']"
routerLinkActive
#renewalHistory="routerLinkActive"
[active]="renewalHistory.isActive"
>
<fa-icon icon="history" class="fd-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Renewal History' | translate }}</span>
</a>
}
<a
mat-tab-link
class="fd-tab"
@@ -16,6 +16,8 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config';
@@ -98,6 +100,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
Payment: 'transact',
'Premature Close': 'danger',
'Undo Activation': 'admin',
Close: 'danger',
@@ -121,6 +124,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'percentage',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
Payment: 'sync-alt',
'Premature Close': 'times-circle',
'Undo Activation': 'undo',
Close: 'times-circle',
@@ -260,9 +264,35 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
case 'Payment':
this.openPaymentDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
// The server bounds a partial reinvest by the balance AFTER it posts interest to the chosen date,
// so the dialog needs both figures plus the maturity date to know which one that will be.
accountBalance: this.fixedDepositsAccountData.summary?.accountBalance,
maturityDate: this.fixedDepositsAccountData.maturityDate
}
});
paymentDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -358,6 +388,20 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* Opens a printable deposit certificate for this account.
*/
printCertificate() {
this.dialog.open(DepositCertificateDialogComponent, {
width: '900px',
data: {
accountType: 'FD',
accountData: this.fixedDepositsAccountData,
clientId: this.fixedDepositsAccountData.clientId
}
});
}
/**
* Disables withhold tax for fixed deposits account
* Fixed deposits endpoint is not supported so using Savings endpoint.
@@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration {
},
{
name: 'Add Charge'
},
{
name: 'Payment'
}
];
break;
@@ -83,6 +83,22 @@
}}</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedFromAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested From' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedFromLink">{{ fixedDepositsAccountData.reinvestedFromAccountNo }}</a>
</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedIntoAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested Into' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedIntoLink">{{ fixedDepositsAccountData.reinvestedIntoAccountNo }}</a>
</span>
</div>
}
</div>
</div>
@@ -48,4 +48,25 @@ export class GeneralTabComponent {
this.entityType = 'Center';
}
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
get reinvestedFromLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedFromAccountId;
return id ? this.siblingAccountLink(id) : null;
}
get reinvestedIntoLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId;
return id ? this.siblingAccountLink(id) : null;
}
}
@@ -36,6 +36,17 @@
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.annualInterestRate | formatNumber }} %</td>
</ng-container>
<ng-container matColumnDef="seniorCitizen">
<th mat-header-cell *matHeaderCellDef><fa-icon icon="user-shield" class="m-r-5"></fa-icon>Senior Citizen</th>
<td mat-cell *matCellDef="let chartSlab">
{{
chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined
? (chartSlab.seniorCitizenRate | formatNumber) + ' %'
: '-'
}}
</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Description' | translate }}</th>
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.description }}</td>
@@ -71,6 +71,7 @@ export class InterestRateChartTabComponent {
'period',
'amountRange',
'interest',
'seniorCitizen',
'description',
'actions'
];
@@ -0,0 +1,139 @@
<!--
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="rh-container">
@if (renewalHistory.length) {
<!-- ─── Summary strip ──────────────────────────────────────────── -->
<div class="rh-summary">
<div class="rh-summary__item">
<span class="rh-summary__label">FD Number</span>
<span class="rh-summary__value">{{ fdNumber }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Terms So Far</span>
<span class="rh-summary__value">{{ renewalHistory.length }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Original Principal</span>
<span class="rh-summary__value">{{ originalPrincipal | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--highlight">
<span class="rh-summary__label">Current Value</span>
<span class="rh-summary__value">{{ currentValue | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--positive">
<span class="rh-summary__label">Lifetime Interest</span>
<span class="rh-summary__value">{{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }}</span>
</div>
</div>
<!-- ─── Vertical timeline ──────────────────────────────────────── -->
<div class="rh-timeline">
@for (
term of renewalHistory;
track term.historyid ?? term.accountid + '-' + term.renewalcycleno;
let last = $last
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__pulse"></span>
}
</span>
@if (!last) {
<span class="rh-node__line"></span>
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
<fa-icon [icon]="renewalTypeIcon(term)"></fa-icon>
{{ renewalTypeLabel(term) }}
</span>
}
</div>
<button type="button" class="rh-chart-btn" (click)="openRateChart(term)">
<fa-icon icon="table"></fa-icon>
View Rate Chart
</button>
</div>
<div class="rh-card__period">
<fa-icon icon="calendar-alt" class="rh-card__period-ico"></fa-icon>
<span>{{ term.activatedondate | dateFormat }}</span>
<fa-icon icon="arrow-right" class="rh-card__period-arrow"></fa-icon>
<span>{{ term.maturitydate | dateFormat }}</span>
@if (tenureLabel(term)) {
<span class="rh-card__tenure">({{ tenureLabel(term) }})</span>
}
</div>
<div class="rh-stats">
<div class="rh-stat">
<span class="rh-stat__label">Principal</span>
<span class="rh-stat__value">{{ term.depositamount | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">Rate</span>
<span class="rh-stat__value">{{ term.interestrate }}%</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: currency.code : 'symbol-narrow')
}}
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
</div>
</div>
<div class="rh-card__foot">
<fa-icon icon="info-circle" class="rh-card__foot-ico"></fa-icon>
<span>
Ledger account:
@if (term.accountid === fixedDepositsAccountData.id) {
<strong>{{ term.accountno }}</strong>
} @else {
<a [routerLink]="siblingLink(term.accountid)">{{ term.accountno }}</a>
}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="rh-empty">
<fa-icon icon="history" class="rh-empty__icon"></fa-icon>
<p class="rh-empty__text">{{ 'labels.messages.No Renewal History Available' | translate }}</p>
</div>
}
</div>
@@ -0,0 +1,364 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #2563eb;
$accent-soft: #eff6ff;
.rh-container {
max-width: 860px;
margin: 0 auto;
padding: 4px 0 24px;
}
/* ─── Summary strip ─────────────────────────────────────────────────── */
.rh-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
background: $border;
border: 1px solid $border;
border-radius: 14px;
overflow: hidden;
margin-bottom: 28px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: #fff;
}
&__label {
font-size: 10.5px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.04em;
}
&__value {
font-size: 15px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
}
&__item--highlight {
background: $accent-soft;
.rh-summary__value {
color: $accent;
}
}
&__item--positive {
background: $positive-soft;
.rh-summary__value {
color: $positive;
}
}
}
@media (width <= 720px) {
.rh-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ─── Timeline rail ──────────────────────────────────────────────────── */
.rh-timeline {
display: flex;
flex-direction: column;
}
.rh-node {
display: flex;
gap: 18px;
}
.rh-node__rail {
flex: none;
width: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.rh-node__dot {
position: relative;
width: 16px;
height: 16px;
margin-top: 22px;
border-radius: 50%;
background: #cbd5e1;
border: 3px solid #fff;
box-shadow: 0 0 0 2px #cbd5e1;
flex: none;
&--active {
background: $positive;
box-shadow: 0 0 0 2px $positive;
}
}
.rh-node__pulse {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid $positive;
opacity: 0.6;
animation: rh-pulse 1.8s ease-out infinite;
}
@keyframes rh-pulse {
0% {
transform: scale(0.6);
opacity: 0.7;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
.rh-node__line {
flex: 1;
width: 2px;
min-height: 24px;
background: $border;
margin: 4px 0;
}
/* ─── Term card ──────────────────────────────────────────────────────── */
.rh-card {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 16px 18px;
margin-bottom: 20px;
transition: box-shadow 0.15s ease;
&--active {
border-color: rgb(4 120 87 / 20%);
box-shadow: 0 2px 10px rgb(4 120 87 / 8%);
}
&:hover {
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
&__title-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: $navy;
}
&__period {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12.5px;
color: $slate;
font-weight: 500;
&-ico {
color: $muted;
font-size: 11px;
}
&-arrow {
color: $muted;
font-size: 10px;
}
}
&__tenure {
color: $muted;
font-weight: 400;
}
&__foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed $border;
font-size: 11.5px;
color: $muted;
&-ico {
font-size: 10px;
}
a {
color: $accent;
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
/* ─── Status badge / auto-renewed tag ───────────────────────────────── */
.rh-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $soft;
color: $muted;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
&--active {
background: $positive-soft;
color: $positive;
}
}
.rh-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $accent-soft;
color: $accent;
font-size: 10.5px;
font-weight: 600;
}
/* ─── View Rate Chart button ─────────────────────────────────────────── */
.rh-chart-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border: 1px solid $border;
border-radius: 999px;
background: #fff;
color: $accent;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: $accent-soft;
border-color: $accent;
}
}
/* ─── Stat grid ──────────────────────────────────────────────────────── */
.rh-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
@media (width <= 640px) {
.rh-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rh-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 9px 12px;
background: $soft;
border-radius: 10px;
&__label {
font-size: 10px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__value {
font-size: 13.5px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
&--positive {
color: $positive;
}
}
&--highlight {
background: $accent-soft;
.rh-stat__value {
color: $accent;
}
}
}
/* ─── Empty state ────────────────────────────────────────────────────── */
.rh-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 16px;
color: $muted;
&__icon {
font-size: 30px;
opacity: 0.4;
}
&__text {
margin: 0;
font-size: 13px;
}
}
@@ -0,0 +1,235 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { Currency } from 'app/shared/models/general.model';
import { CurrencyPipe } from '@angular/common';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductsService } from 'app/products/products.service';
import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component';
/**
* Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab
* rather than a card on General, since a long-lived FD can carry many terms and each one's full detail
* (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably.
*/
@Component({
selector: 'mifosx-renewal-history-tab',
templateUrl: './renewal-history-tab.component.html',
styleUrls: ['./renewal-history-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
CurrencyPipe,
DateFormatPipe
]
})
export class RenewalHistoryTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
fixedDepositsAccountData: any;
currency: Currency;
/**
* The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once
* from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the
* premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history
* carries a senior-citizen column, since that concept only exists on the base rate master.
*/
private activeBaseSlabs: any[] = [];
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.fixedDepositsAccountData = data.fixedDepositsAccountData;
this.currency = this.fixedDepositsAccountData.currency;
});
}
ngOnInit(): void {
const fdType = this.fixedDepositsAccountData?.fdType;
if (!fdType) {
return;
}
this.productsService.getFdBaseInterestRates(fdType).subscribe({
next: (rates: any[]) => {
const active = (rates || []).find((r: any) => r.isActive);
this.activeBaseSlabs = active?.slabs ?? [];
},
error: () => {}
});
}
/**
* The published senior-citizen rate for a tenure band, matched by period type and range against the active
* base rate card. Returns null when nothing matches (no published base version for this FD type, or this
* particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank
* rather than a misleading zero.
*/
private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null {
const match = this.activeBaseSlabs.find(
(s) =>
s.periodTypeEnum === periodTypeEnum &&
s.fromPeriod === fromPeriod &&
(s.toPeriod ?? null) === (toPeriod ?? null)
);
return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null;
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
siblingLink(targetAccountId: number): string | null {
return this.siblingAccountLink(targetAccountId);
}
/**
* Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they
* arrive straight from the SQL result-set column labels.
*/
get renewalHistory(): any[] {
return this.fixedDepositsAccountData?.renewalHistory ?? [];
}
get fdNumber(): string {
return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo;
}
/** The very first term's opening principal -- what the customer originally deposited. */
get originalPrincipal(): number | null {
return this.renewalHistory[0]?.depositamount ?? null;
}
/** The latest term's settled or projected value -- what the FD is worth right now. */
get currentValue(): number | null {
const latest = this.renewalHistory[this.renewalHistory.length - 1];
return latest ? this.maturityFor(latest) : null;
}
/**
* Total interest earned across every term, settled terms plus the running term's interest-to-date. Null
* only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero".
*/
get lifetimeInterestEarned(): number | null {
if (!this.renewalHistory.length) {
return null;
}
let total = 0;
for (const term of this.renewalHistory) {
if (term.interestearned != null) {
total += Number(term.interestearned);
} else {
const projected = this.maturityFor(term);
if (projected != null && term.depositamount != null) {
total += Number(projected) - Number(term.depositamount);
}
}
}
return total;
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
/** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */
private static readonly PERIOD_TYPE_LABELS: Record<number, string> = {
0: 'Days',
1: 'Weeks',
2: 'Months',
3: 'Years'
};
/** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */
tenureLabel(term: any): string {
if (term?.tenure == null) {
return '';
}
const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? '';
return unit ? `${term.tenure} ${unit}` : `${term.tenure}`;
}
/** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */
renewalTypeLabel(term: any): string | null {
if (term?.renewaltype === 'AUTO') return 'Auto-Renewed';
if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff';
return null;
}
renewalTypeIcon(term: any): string {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
* A term's chart is only archived once it closes via an in-place renewal, so the term still running has no
* archived rows -- for that one term (recognisable as the one matching this account's own id), the
* account's own live chart is shown instead, since it is the same data. The two sources use different key
* casing and represent the period type differently (a raw enum int here, an expanded {value} object on the
* live chart), which this maps into one consistent shape the dialog can render without knowing which source
* it came from.
*/
private chartSlabsFor(term: any): any[] {
const raw = term?.chartSlabs?.length
? term.chartSlabs
: term?.accountid === this.fixedDepositsAccountData?.id
? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? [])
: [];
return raw.map((slab: any) => {
const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum;
const fromPeriod = slab.fromperiod ?? slab.fromPeriod;
const toPeriod = slab.toperiod ?? slab.toPeriod;
// The archived/live row's own senior_citizen_rate is the true figure as published when this term
// was actually priced -- preferred whenever present. The live lookup against today's base rate
// card is only a fallback, for the currently-running term (not archived yet) or an older archive
// row written before that column existed -- flagged as such so the dialog can say so.
const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null;
return {
description: slab.description,
fromPeriod,
toPeriod,
periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '',
annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate,
seniorCitizenRate:
persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null),
seniorCitizenRateIsLive: persistedSeniorRate == null
};
});
}
/** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */
openRateChart(term: any): void {
this.dialog.open(TermRateChartDialogComponent, {
width: '560px',
data: {
term,
slabs: this.chartSlabsFor(term),
isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen
}
});
}
}
@@ -0,0 +1,77 @@
<!--
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="trc-dialog">
<h2 mat-dialog-title class="trc-header">
<span class="trc-header__icon"><fa-icon icon="table"></fa-icon></span>
<span class="trc-header__text">
<span class="trc-header__title">{{ termLabel }} — Rate Chart</span>
<span class="trc-header__sub"
>{{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }}</span
>
</span>
</h2>
<div mat-dialog-content class="trc-content">
@if (slabs.length) {
<table class="trc-table">
<thead>
<tr>
<th>Description</th>
<th>Tenure</th>
<th class="trc-table__rate-col">Rate</th>
<th class="trc-table__rate-col">
<fa-icon icon="user-shield" class="trc-table__sc-ico"></fa-icon>
Senior Citizen
</th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track $index) {
<tr [class.trc-table__row--applied]="isAppliedSlab(slab) || isAppliedSeniorSlab(slab)">
<td>{{ slab.description }}</td>
<td>{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }}</td>
<td class="trc-table__rate-col">
{{ slab.annualInterestRate }}%
@if (isAppliedSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
<td class="trc-table__rate-col trc-table__sc-col">
{{
slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined
? slab.seniorCitizenRate + '%'
: '—'
}}
@if (isAppliedSeniorSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
</tr>
}
</tbody>
</table>
@if (hasLiveSeniorRate) {
<p class="trc-note">
<fa-icon icon="info-circle"></fa-icon>
Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for
reference.
</p>
}
} @else {
<div class="trc-empty">
<fa-icon icon="info-circle" class="trc-empty__icon"></fa-icon>
<p>No rate chart was archived for this term.</p>
</div>
}
</div>
<div mat-dialog-actions align="end" class="trc-actions">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</div>
</div>
@@ -0,0 +1,159 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #b8860b;
.trc-dialog {
min-width: 420px;
max-width: 540px;
}
.trc-header {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 4px;
&__icon {
flex: none;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 15px;
}
&__text {
display: flex;
flex-direction: column;
min-width: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $navy;
line-height: 1.3;
}
&__sub {
font-size: 12px;
color: $muted;
font-weight: 500;
}
}
.trc-content {
padding-top: 4px;
}
.trc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
padding: 8px 10px;
background: $soft;
color: $muted;
font-weight: 600;
text-align: left;
border-bottom: 1px solid $border;
}
td {
padding: 9px 10px;
color: $slate;
border-bottom: 1px solid $border;
font-variant-numeric: tabular-nums;
}
&__rate-col {
text-align: right;
}
&__row--applied {
background: $positive-soft;
td {
color: $positive;
font-weight: 700;
}
}
&__sc-ico {
color: $accent;
margin-right: 4px;
}
&__sc-col {
color: $accent;
font-weight: 600;
}
}
.trc-applied-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 10px;
background: $positive;
color: #fff;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
vertical-align: middle;
}
.trc-note {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 10px 2px 0;
color: $muted;
font-size: 11px;
font-style: italic;
line-height: 1.4;
}
.trc-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 8px;
color: $muted;
text-align: center;
&__icon {
font-size: 22px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 13px;
}
}
.trc-actions {
padding-top: 6px;
}
@@ -0,0 +1,93 @@
/**
* 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, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the
* timeline: a chart can run to several tenure bands, and showing it in place would push every later term
* down the page every time one is opened.
*/
@Component({
selector: 'mifosx-term-rate-chart-dialog',
templateUrl: './term-rate-chart-dialog.component.html',
styleUrls: ['./term-rate-chart-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
FaIconComponent
]
})
export class TermRateChartDialogComponent {
dialogRef = inject<MatDialogRef<TermRateChartDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
get term(): any {
return this.data.term;
}
get slabs(): any[] {
return this.data.slabs ?? [];
}
get termLabel(): string {
return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno;
}
/** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */
get isSeniorCitizen(): boolean {
return !!this.data.isSeniorCitizen;
}
/**
* True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the
* true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an
* older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all.
*/
get hasLiveSeniorRate(): boolean {
return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null);
}
/**
* True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen
* term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}.
* Compared as numbers since one side arrives as a string from the currency-agnostic archive row.
*/
isAppliedSlab(slab: any): boolean {
return (
!this.isSeniorCitizen &&
this.term?.interestrate != null &&
Number(slab.annualInterestRate) === Number(this.term.interestrate)
);
}
/** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */
isAppliedSeniorSlab(slab: any): boolean {
return (
this.isSeniorCitizen &&
slab.seniorCitizenRate != null &&
this.term?.interestrate != null &&
Number(slab.seniorCitizenRate) === Number(this.term.interestrate)
);
}
}
@@ -36,7 +36,11 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.id }}
@if (transaction.isRenewalEvent) {
<fa-icon icon="sync-alt" class="txn-tab__renewal-ico"></fa-icon>
} @else {
{{ transaction.id }}
}
</td>
</ng-container>
@@ -72,7 +76,13 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
{{
transaction.isRenewalEvent
? '—'
: isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
</td>
</ng-container>
@@ -84,7 +94,13 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
{{
transaction.isRenewalEvent
? '—'
: !isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
</td>
</ng-container>
@@ -96,36 +112,40 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.runningBalance | formatNumber }}
{{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let transaction; let idx = index">
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
@if (transaction.isRenewalEvent) {
<span class="txn-tab__renewal-tag">No ledger impact</span>
} @else {
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
}
</td>
</ng-container>
@@ -97,4 +97,32 @@ table {
color: $muted;
font-style: italic;
}
.mat-mdc-cell.renewal-marker {
background: #eff6ff;
color: $blue;
font-style: italic;
cursor: default;
}
.mat-mdc-row:has(.renewal-marker):hover {
background: #eff6ff;
cursor: default;
}
}
.txn-tab__renewal-ico {
color: $blue;
}
.txn-tab__renewal-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: #dbeafe;
color: $blue;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit {
*/
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.transactionsData = data.fixedDepositsAccountData.transactions;
this.transactionsData = this.withRenewalMarkers(
data.fixedDepositsAccountData.transactions,
data.fixedDepositsAccountData.renewalHistory
);
this.clientId = this.route.parent.snapshot.params['clientId'];
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
this.status = data.fixedDepositsAccountData.status.value;
});
}
/**
* An in-place renewal moves no money -- the matured balance simply becomes the new term's opening
* principal -- so it posts no withdrawal, deposit, or journal entry (see
* DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal
* would be invisible in this list even though the account's rate and term just changed. These rows
* are synthesized purely for display from the renewal history already loaded for the Renewal History
* tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry
* lookup, and every real transaction's running balance stay exactly as they were.
*/
private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] {
const markers: any[] = (renewalHistory ?? [])
.filter((term: any) => term.renewalcycleno > 0)
.map((term: any) => {
const marker: any = {
isRenewalEvent: true,
id: null,
date: term.activatedondate,
amount: null,
runningBalance: null,
reversed: false,
transfer: null,
renewalCycleNo: term.renewalcycleno,
transactionType: {
value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed',
accrual: false,
withdrawal: false,
feeDeduction: false,
overdraftInterest: false,
withholdTax: false
}
};
return marker;
});
return [
...(transactions ?? []),
...markers
].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date));
}
/** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */
private toEpoch(value: any): number {
if (!value) {
return 0;
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
ngOnInit() {
this.hideAccrualsParam = new UntypedFormControl(false);
this.hideReversedParam = new UntypedFormControl(false);
@@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit {
* @param {any} transactionsData Transactions Data
*/
showTransactions(transactionsData: any) {
if (transactionsData.isRenewalEvent) {
return;
}
if (transactionsData.transfer) {
this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], {
relativeTo: this.route
@@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit {
this.dataSource.sort = this.sort;
}
savingsTransactionColor(transaction: SavingsAccountTransaction): string {
savingsTransactionColor(transaction: any): string {
if (transaction.isRenewalEvent) {
return 'renewal-marker';
}
if (transaction.reversed) {
return 'strike';
} else if (transaction.transfer) {
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveFixedDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the fixed deposit account,
* if successful redirects to the fixed deposit account.
* Submits the form, approves the fixed deposit account, then immediately activates it on the
* same date -- Approve and Activate are still two separate Fineract commands underneath (each
* shows up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveFixedDepositsAccountFormData = this.approveFixedDepositsAccountForm.value;
@@ -98,7 +99,16 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de
import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver';
import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver';
import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component';
import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component';
import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component';
const routes: Routes = [
@@ -80,6 +81,15 @@ const routes: Routes = [
routeParamBreadcrumb: false
}
},
{
path: 'renewal-history',
component: RenewalHistoryTabComponent,
data: {
title: 'Fixed Deposit Account Renewal History',
breadcrumb: 'Renewal History',
routeParamBreadcrumb: false
}
},
{
path: 'transactions',
component: TransactionsTabComponent,
@@ -87,6 +87,17 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also
* include Partial Re-Invest, which the closure commands exclude because only Payment implements it.
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
*/
getFixedDepositsAccountPaymentTemplate(accountId: string): Observable<any> {
const httpParams = new HttpParams().set('command', 'payment');
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
@@ -33,6 +33,7 @@
<mifosx-recurring-deposits-account-details-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
>
</mifosx-recurring-deposits-account-details-step>
@@ -111,7 +112,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cra-tab-body">
@if (recurringDepositsAccountProductTemplate) {
<mifosx-recurring-deposits-account-maturity-terms-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-recurring-deposits-account-maturity-terms-step>
}
<div class="cra-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cra-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
@@ -132,6 +166,7 @@
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
>
@@ -24,6 +24,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RecurringDepositsAccountDetailsStepComponent,
RecurringDepositsAccountCurrencyStepComponent,
RecurringDepositsAccountInterestRateChartStepComponent,
RecurringDepositsAccountMaturityTermsStepComponent,
RecurringDepositsAccountPreviewStepComponent
]
})
@@ -61,6 +63,12 @@ export class CreateRecurringDepositsAccountComponent {
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
/** Recurring Deposits Account Template */
recurringDepositsAccountTemplate: any;
/** Recurring Deposit Account Product Template */
@@ -68,7 +76,7 @@ export class CreateRecurringDepositsAccountComponent {
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Chart, Preview. */
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
@@ -258,6 +266,7 @@ export class CreateRecurringDepositsAccountComponent {
...details,
...terms,
...settings,
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
...productDefaults,
clientId: this.recurringDepositsAccountTemplate.clientId,
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
@@ -35,6 +35,7 @@
<mifosx-recurring-deposits-account-details-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
>
</mifosx-recurring-deposits-account-details-step>
@@ -115,7 +116,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cra-tab-body">
@if (recurringDepositsAccountProductTemplate) {
<mifosx-recurring-deposits-account-maturity-terms-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-recurring-deposits-account-maturity-terms-step>
}
<div class="cra-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cra-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
@@ -136,6 +170,7 @@
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
>
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RecurringDepositsAccountDetailsStepComponent,
RecurringDepositsAccountCurrencyStepComponent,
RecurringDepositsAccountInterestRateChartStepComponent,
RecurringDepositsAccountMaturityTermsStepComponent,
RecurringDepositsAccountPreviewStepComponent
]
})
@@ -63,13 +65,19 @@ export class EditRecurringDepositAccountComponent {
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
/** Full account data + template from resolver */
recurringDepositsAccountAndTemplate: any;
/** Product template emitted by details step */
recurringDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
@@ -277,6 +285,7 @@ export class EditRecurringDepositAccountComponent {
...details,
...terms,
...settings,
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
...productDefaults,
clientId: this.recurringDepositsAccountAndTemplate.clientId,
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the recurring deposit account,
* if successful redirects to the recurring deposit account.
* Submits the form, approves the recurring deposit account, then immediately activates it on
* the same date -- Approve and Activate are still two separate Fineract commands underneath
* (each shows up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveRecurringDepositsAccountFormData = this.approveRecurringDepositsAccountForm.value;
@@ -103,7 +104,16 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveRecurringDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -7,7 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
OnInit,
OnChanges,
SimpleChanges,
Input,
Output,
EventEmitter,
inject,
ChangeDetectorRef
} from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
@@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
IntroducerSearchComponent
]
})
export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService);
private settingsService = inject(SettingsService);
@@ -56,6 +66,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
/** Recurring Deposits Account Template */
@Input() recurringDepositsAccountTemplate: any;
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
@Input() externalLinkedAccountId: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -198,6 +212,23 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
this.dalSearch = '';
}
ngOnChanges(changes: SimpleChanges): void {
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
return;
}
const id = this.externalLinkedAccountId;
if (!id) return;
const savings = this.dalAllSavings.find((a: any) => a.id === id);
const current = this.dalAllCurrent.find((a: any) => a.id === id);
if (savings) {
this.dalSelectedType = 'SAVINGS';
this.selectedLinkedAccount = savings;
} else if (current) {
this.dalSelectedType = 'CURRENT';
this.selectedLinkedAccount = current;
}
}
private loadClientAccounts(): void {
const clientId = String(this.recurringDepositsAccountTemplate?.clientId);
if (!clientId) return;
@@ -0,0 +1,108 @@
<!--
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/.
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
<div class="dal-card flex-98">
<div class="dal-card__header">
<div class="dal-card__header-left">
<div class="dal-card__icon">
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
<div class="dal-card__body">
@if (selectedTransferAccount) {
<div class="dal-selected">
<div class="dal-selected__info">
<span class="dal-type-badge dal-type-badge--savings">
<mat-icon>savings</mat-icon>
Savings
</span>
<div class="dal-selected__details">
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
</div>
@if (selectedTransferAccount.summary?.accountBalance != null) {
<div class="dal-selected__balance">
<span class="dal-selected__bal-label">Balance</span>
<span class="dal-selected__bal-amount">
{{ selectedTransferAccount.currency?.code }}
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
</span>
</div>
}
</div>
<button
mat-stroked-button
color="primary"
type="button"
class="dal-change-btn"
(click)="clearTransferAccount()"
>
<mat-icon>swap_horiz</mat-icon>
Change
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
({{ account.accountNo }}) {{ account.savingsProductName }}
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
.dal-card {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
&__header-left {
display: flex;
align-items: center;
gap: 14px;
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #0ea5e9;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
&__sub {
display: block;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
&__body {
padding: 20px 24px;
}
}
.dal-selected {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: 2px solid #0ea5e9;
border-radius: 12px;
background: #f0f9ff;
&__info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
flex-wrap: wrap;
}
&__details {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
&__accno {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
font-family: monospace;
}
&__product {
font-size: 12px;
color: #64748b;
}
&__balance {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
&__bal-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__bal-amount {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
}
.dal-change-btn {
white-space: nowrap;
}
.dal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--savings {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
mat-icon {
color: #2563eb;
}
}
}
@@ -0,0 +1,116 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Recurring Deposit Account Maturity Terms Step -- what happens to this account at maturity
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
* per account rather than silently inherited from the product.
*/
@Component({
selector: 'mifosx-recurring-deposits-account-maturity-terms-step',
templateUrl: './recurring-deposits-account-maturity-terms-step.component.html',
styleUrls: ['./recurring-deposits-account-maturity-terms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
]
})
export class RecurringDepositsAccountMaturityTermsStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
@Input() recurringDepositsAccountTemplate: any;
@Input() recurringDepositsAccountProductTemplate: any;
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
@Input() linkedSavingsAccountId: number | null = null;
closureSettingsForm: UntypedFormGroup;
maturityInstructionOptions: any[] = [];
savingsAccountsData: any[] = [];
preClosurePenalInterestOnTypeData: any[] = [];
constructor() {
this.closureSettingsForm = this.formBuilder.group({
maturityInstructionId: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new RD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
);
} else {
this.closureSettingsForm.removeControl('transferToSavingsId');
}
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.linkedSavingsAccountId) {
const control = this.closureSettingsForm.get('transferToSavingsId');
if (control && !control.value && this.linkedSavingsAccountId) {
control.setValue(this.linkedSavingsAccountId);
}
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (template) {
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
// the account matures, before the customer has actually collected it -- disabled everywhere.
this.maturityInstructionOptions = (template.maturityInstructionOptions ?? []).filter(
(option: any) => option.id !== 100
);
this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId: template.preClosurePenalInterestOnType?.id ?? ''
});
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
}
}
return settings;
}
}
@@ -853,6 +853,41 @@
}
</div>
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-flag-checkered preview-section__icon"></i>
<span class="preview-section__title">Maturity &amp; Pre-mature Closure</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
</div>
@if (closureSettings?.transferToSavingsId) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
</div>
}
</div>
</div>
</div>
@if (!hideButtons) {
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
@@ -201,7 +201,29 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
/** Expand Chart Slab Index used in the view */
expandChartSlabIndex: number;
constructor() {}
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
@Input() closureSettings: any = {};
get closureMaturityInstructionLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.maturityInstructionOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
return match?.value ?? '—';
}
get closureTransferToSavingsLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const accounts = template?.savingsAccounts ?? [];
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—';
}
ngOnChanges() {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
@@ -17,6 +17,7 @@ import {
} from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Currency } from 'app/shared/models/general.model';
import { OptionData } from 'app/shared/models/option-data.model';
import { MatCheckbox } from '@angular/material/checkbox';
import { InputAmountComponent } from '../../../../shared/input-amount/input-amount.component';
import { MatDivider } from '@angular/material/divider';
@@ -61,6 +62,10 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
periodFrequencyTypeData: any[] = [];
/** Preclosure Penal Interest Type on Data */
preClosurePenalInterestOnTypeData: any[] = [];
/** Options for what happens to the account at maturity */
maturityInstructionOptions: OptionData[];
/** Savings accounts eligible as a maturity-proceeds transfer target */
savingsAccountsData: any;
/** Tax Group */
taxGroup: any;
currency: Currency | null = null;
@@ -91,6 +96,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
: '',
maturityInstructions: recurringDepositsAccount.maturityInstructions ?? false,
creditMaturityProceeds: recurringDepositsAccount.creditMaturityProceeds ?? false,
maturityInstructionId: recurringDepositsAccount.maturityInstructionId ?? '',
penaltyGracePeriodMissedInstallments: recurringDepositsAccount.penaltyGracePeriodMissedInstallments ?? false,
panelRequestApplicable: recurringDepositsAccount.panelRequestApplicable ?? false,
noPenaltyNomineeHeirClosure: recurringDepositsAccount.noPenaltyNomineeHeirClosure ?? false,
@@ -182,6 +188,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
lockinPeriodFrequencyType: [''],
maturityInstructions: [false],
creditMaturityProceeds: [false],
maturityInstructionId: [''],
penaltyGracePeriodMissedInstallments: [false],
panelRequestApplicable: [false],
noPenaltyNomineeHeirClosure: [false],
@@ -217,6 +224,8 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
this.lockinPeriodFrequencyTypeData = recurringDepositsAccount.lockinPeriodFrequencyTypeOptions;
this.periodFrequencyTypeData = recurringDepositsAccount.periodFrequencyTypeOptions;
this.preClosurePenalInterestOnTypeData = recurringDepositsAccount.preClosurePenalInterestOnTypeOptions;
this.maturityInstructionOptions = recurringDepositsAccount.maturityInstructionOptions;
this.savingsAccountsData = recurringDepositsAccount.savingsAccounts;
}
buildDependencies() {
@@ -233,6 +242,16 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
this.recurringDepositAccountSettingsForm.addControl('recurringFrequencyType', new UntypedFormControl(''));
}
});
this.recurringDepositAccountSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
if (value > 100) {
this.recurringDepositAccountSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl('', Validators.required)
);
} else {
this.recurringDepositAccountSettingsForm.removeControl('transferToSavingsId');
}
});
}
get recurringDepositAccountSettings() {
@@ -87,6 +87,19 @@
<!-- Action menu (kebab) -->
<div class="rd-hero__actions">
@if (
!recurringDepositsAccountData.status.rejected &&
!recurringDepositsAccountData.status.submittedAndPendingApproval
) {
<button
mat-icon-button
matTooltip="Print Certificate"
(click)="printCertificate()"
aria-label="Print certificate"
>
<fa-icon icon="print"></fa-icon>
</button>
}
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
@@ -22,6 +22,7 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
@@ -426,6 +427,20 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
});
}
/**
* Opens a printable deposit certificate for this account.
*/
printCertificate() {
this.dialog.open(DepositCertificateDialogComponent, {
width: '900px',
data: {
accountType: 'RD',
accountData: this.recurringDepositsAccountData,
clientId: this.recurringDepositsAccountData.clientId
}
});
}
/**
* Disables withhold tax for recurring deposits account
* Recurring deposits endpoint is not supported so using Savings endpoint.
@@ -21,6 +21,33 @@
<mat-checkbox formControlName="lienMarked">Lien Marked</mat-checkbox>
<mat-checkbox formControlName="withdrawalFeeForTransfers">Apply Withdrawal Fee for Transfers</mat-checkbox>
</div>
<mat-checkbox class="fdps-preclosure-toggle" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (fixedDepositProductSettingsForm.value.preClosurePenalApplicable) {
<div class="fdps-lockin-fields">
<mat-form-field appearance="outline" class="fdps-field">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="fdps-field">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (
preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData;
track preClosurePenalInterestOnType
) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
</div>
</div>
@@ -130,6 +130,15 @@ $gray-900: #202124;
}
}
.fdps-preclosure-toggle {
display: block;
margin: 4px 0 16px;
padding-bottom: 16px;
border-bottom: 1px solid $gray-200;
font-size: 14px;
color: $gray-900;
}
.fdps-layout {
display: grid;
gap: 16px;
@@ -30,6 +30,7 @@ export class FixedDepositProductSettingsStepComponent implements OnInit {
fixedDepositProductSettingsForm: UntypedFormGroup;
lockinPeriodFrequencyTypeData: any;
preClosurePenalInterestOnTypeData: any;
constructor() {
this.createFixedDepositProductSettingsForm();
@@ -37,6 +38,7 @@ export class FixedDepositProductSettingsStepComponent implements OnInit {
ngOnInit() {
this.lockinPeriodFrequencyTypeData = this.fixedDepositProductsTemplate.lockinPeriodFrequencyTypeOptions;
this.preClosurePenalInterestOnTypeData = this.fixedDepositProductsTemplate.preClosurePenalInterestOnTypeOptions;
if (this.fixedDepositProductsTemplate?.id) {
this.fixedDepositProductSettingsForm.patchValue({
@@ -48,6 +50,9 @@ export class FixedDepositProductSettingsStepComponent implements OnInit {
enableLockinPeriod: this.fixedDepositProductsTemplate.enableLockinPeriod ?? false,
lockinPeriodFrequency: this.fixedDepositProductsTemplate.lockinPeriodFrequency ?? '',
lockinPeriodFrequencyType: this.fixedDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '',
preClosurePenalApplicable: this.fixedDepositProductsTemplate.preClosurePenalApplicable ?? false,
preClosurePenalInterest: this.fixedDepositProductsTemplate.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId: this.fixedDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '',
maturityInstructions: this.fixedDepositProductsTemplate.maturityInstructions ?? false,
creditMaturityProceeds: this.fixedDepositProductsTemplate.creditMaturityProceeds ?? false,
penaltyGracePeriodMissedInstallments:
@@ -76,6 +81,12 @@ export class FixedDepositProductSettingsStepComponent implements OnInit {
Validators.min(0)
],
lockinPeriodFrequencyType: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: [''],
maturityInstructions: [false],
creditMaturityProceeds: [false],
penaltyGracePeriodMissedInstallments: [false],
@@ -21,6 +21,33 @@
<mat-checkbox formControlName="lienMarked">Lien Marked</mat-checkbox>
<mat-checkbox formControlName="withdrawalFeeForTransfers">Apply Withdrawal Fee for Transfers</mat-checkbox>
</div>
<mat-checkbox class="rdps-preclosure-toggle" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (recurringDepositProductSettingsForm.value.preClosurePenalApplicable) {
<div class="rdps-lockin-fields">
<mat-form-field appearance="outline" class="rdps-field">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="rdps-field">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (
preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData;
track preClosurePenalInterestOnType
) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
</div>
</div>
@@ -162,3 +162,12 @@ $gray-900: #202124;
}
}
}
.rdps-preclosure-toggle {
display: block;
margin: 4px 0 16px;
padding-bottom: 16px;
border-bottom: 1px solid $gray-200;
font-size: 14px;
color: $gray-900;
}
@@ -28,6 +28,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
recurringDepositProductSettingsForm: UntypedFormGroup;
lockinPeriodFrequencyTypeData: any;
preClosurePenalInterestOnTypeData: any;
constructor() {
this.createrecurringDepositProductSettingsForm();
@@ -35,6 +36,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
ngOnInit() {
this.lockinPeriodFrequencyTypeData = this.recurringDepositProductsTemplate.lockinPeriodFrequencyTypeOptions;
this.preClosurePenalInterestOnTypeData = this.recurringDepositProductsTemplate.preClosurePenalInterestOnTypeOptions;
if (this.recurringDepositProductsTemplate?.id) {
this.recurringDepositProductSettingsForm.patchValue({
@@ -46,6 +48,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
enableLockinPeriod: this.recurringDepositProductsTemplate.enableLockinPeriod ?? false,
lockinPeriodFrequency: this.recurringDepositProductsTemplate.lockinPeriodFrequency ?? '',
lockinPeriodFrequencyType: this.recurringDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '',
preClosurePenalApplicable: this.recurringDepositProductsTemplate.preClosurePenalApplicable ?? false,
preClosurePenalInterest: this.recurringDepositProductsTemplate.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId: this.recurringDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '',
maturityInstructions: this.recurringDepositProductsTemplate.maturityInstructions ?? false,
creditMaturityProceeds: this.recurringDepositProductsTemplate.creditMaturityProceeds ?? false,
penaltyGracePeriodMissedInstallments:
@@ -71,6 +76,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
enableLockinPeriod: [false],
lockinPeriodFrequency: [''],
lockinPeriodFrequencyType: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [''],
preClosurePenalInterestOnTypeId: [''],
maturityInstructions: [false],
creditMaturityProceeds: [false],
penaltyGracePeriodMissedInstallments: [false],
@@ -12,9 +12,7 @@
<fa-icon icon="piggy-bank" class="csa-header__icon"></fa-icon>
<div>
<h2 class="csa-header__title">Create Savings Account</h2>
<div class="csa-header__sub">
Pick a product, confirm currency, set digital channels and review before submit.
</div>
<div class="csa-header__sub">Pick a product, confirm currency and review before submit.</div>
</div>
</div>
@@ -76,33 +74,7 @@
</div>
</mat-tab>
<!-- Tab 3: Digital -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="mobile-alt" class="csa-tab__icon"></fa-icon>
Digital
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-digital-step [savingsAccountProductTemplate]="savingsAccountProductTemplate">
</mifosx-savings-account-digital-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 4: Preview / Submit -->
<!-- Tab 3: Preview / Submit -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="csa-tab__icon"></fa-icon>
@@ -62,8 +62,8 @@ export class CreateSavingsAccountComponent {
savingsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
/** Total wizard tabs — Product, Currency, Preview. */
private readonly totalTabs = 3;
/** Guards against duplicate submissions from a double-click while the create request is in flight. */
submitting = false;
@@ -14,7 +14,9 @@
</div>
<div>
<h2 class="act-savings__title">Activate Savings Account</h2>
<p class="act-savings__sub">Set the activation date and opening balance to bring this account live.</p>
<p class="act-savings__sub">
Set the activation date to bring this account live -- funding happens afterwards, as a separate deposit.
</p>
</div>
</div>
@@ -45,18 +47,6 @@
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="act-savings__field">
<mat-label>Opening Balance</mat-label>
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
<span matPrefix class="act-savings__prefix">₹&nbsp;</span>
@if (minRequiredOpeningBalance) {
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
}
@if (activateSavingsAccountForm.controls.openingBalance.hasError('min')) {
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
}
</mat-form-field>
</div>
<div class="act-savings__actions">
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
@@ -41,8 +41,6 @@ export class ActivateSavingsAccountComponent implements OnInit {
private router = inject(Router);
private settingsService = inject(SettingsService);
@Input() minRequiredOpeningBalance: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -76,18 +74,10 @@ export class ActivateSavingsAccountComponent implements OnInit {
* Creates the activate savings account form.
*/
createActivateSavingsAccountForm() {
const minBalance = this.minRequiredOpeningBalance ?? 0;
this.activateSavingsAccountForm = this.formBuilder.group({
activatedOnDate: [
'',
Validators.required
],
openingBalance: [
minBalance,
[
Validators.required,
Validators.min(minBalance)
]
]
});
}
@@ -109,9 +99,6 @@ export class ActivateSavingsAccountComponent implements OnInit {
dateFormat,
locale
};
if (data.openingBalance === 0 || data.openingBalance === null) {
delete data.openingBalance;
}
this.savingsService.executeSavingsAccountCommand(this.accountId, 'activate', data).subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveSavingsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveSavingsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the saving account,
* if successful redirects to the saving account.
* Submits the form, approves the saving account, then immediately activates it on the same
* date -- Approve and Activate are still two separate Fineract commands underneath (each shows
* up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveSavingsAccountFormData = this.approveSavingsAccountForm.value;
@@ -98,7 +99,14 @@ export class ApproveSavingsAccountComponent implements OnInit {
locale
};
this.savingsService.executeSavingsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveSavingsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.savingsService.executeSavingsAccountCommand(this.accountId, 'activate', activationData).subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
});
}
}
@@ -13,9 +13,7 @@
<mifosx-reject-savings-account></mifosx-reject-savings-account>
}
@if (actions['Activate']) {
<mifosx-activate-savings-account
[minRequiredOpeningBalance]="accountData?.minRequiredOpeningBalance"
></mifosx-activate-savings-account>
<mifosx-activate-savings-account></mifosx-activate-savings-account>
}
@if (actions['Undo Approval']) {
<mifosx-undo-approval-savings-account></mifosx-undo-approval-savings-account>
@@ -15,7 +15,6 @@
<h3 class="dig-header__title">Digital &amp; Account Features</h3>
<p class="dig-header__sub">Toggle the facilities available for this account (as per RBI guidelines)</p>
</div>
<span class="dig-header__badge">FROM PRODUCT</span>
</div>
<div class="dig-grid">
@@ -50,12 +50,11 @@ export class SavingsAccountDigitalStepComponent {
}
}
// Note: a new account always starts with every digital facility off, even if the product has
// its own defaults configured -- those are enabled deliberately, one at a time, after the
// account is created and active, not inherited automatically at opening.
@Input()
set savingsAccountProductTemplate(value: any) {
if (!this.hasExistingAccount && value?.accountFacilityConfig) {
this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig };
}
}
savingsAccountProductTemplate: any;
get savingsAccountDigital() {
return { accountFacilityConfig: { ...this.facilityConfig } };
@@ -124,13 +124,11 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
}
}
/** Returns user-modified digital config (from digital step), falling back to product template */
/** Digital facility config as actually captured by a digital step in this wizard -- no fallback
* to the product's own defaults, since account creation no longer has a digital step at all and
* must not preview facilities the officer never actually chose. */
get digitalFacilityConfig(): any {
return (
this.savingsAccountDigital?.accountFacilityConfig ??
this.savingsAccountProductTemplate?.accountFacilityConfig ??
null
);
return this.savingsAccountDigital?.accountFacilityConfig ?? null;
}
get hasDigitalSection(): boolean {
@@ -0,0 +1,202 @@
<!--
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/.
-->
<h2 mat-dialog-title class="dialog-header no-print">
<span>{{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} Certificate</span>
<span class="flex-auto"></span>
</h2>
<div mat-dialog-content class="dialog-content-container">
<!-- Certificate Page Frame -->
<div class="certificate-container" id="deposit-certificate-print">
<div class="certificate-border-outer">
<div class="certificate-border-inner">
<!-- Certificate Watermark Icon/Seal -->
<div class="certificate-watermark">
<mat-icon class="watermark-icon">{{ accountType === 'FD' ? 'savings' : 'event_repeat' }}</mat-icon>
</div>
<div class="certificate-header">
<div class="bank-name">Nexora Co-operative Bank Ltd.</div>
<div class="bank-sub">Mumbai, Maharashtra, India</div>
<div class="cert-title">
{{ accountType === 'FD' ? 'FIXED DEPOSIT CERTIFICATE' : 'RECURRING DEPOSIT CERTIFICATE' }}
</div>
<div class="cert-subtitle">Issued under the Common Seal of the Bank</div>
</div>
<div class="cert-meta-row">
<div class="meta-item">
<span class="meta-label">Certificate No.</span>
<span class="meta-value">{{ certificateNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Account No.</span>
<span class="meta-value">{{ accountData.fdAccountNumber || accountData.accountNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Date of Issue</span>
<span class="meta-value">{{ issueDate | dateFormat }}</span>
</div>
</div>
<div class="cert-body-section">
<p class="cert-main-text">
This is to certify that
<strong class="highlight-text">{{ accountData.clientName || accountData.groupName }}</strong>
holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number
<strong class="highlight-text">{{ accountData.fdAccountNumber || accountData.accountNo }}</strong>
with <strong class="highlight-text">Nexora Co-operative Bank Ltd.</strong>, as per the particulars set out
below.
<ng-container *ngIf="renewalCount">
This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the
full renewal history is set out below.
</ng-container>
</p>
<div class="issued-to-details">
<div class="issued-to-title">ISSUED TO:</div>
<div class="client-name-details">{{ accountData.clientName || accountData.groupName }}</div>
<div class="client-ids">
<span *ngIf="accountData.clientAccountNo"
><strong>Customer ID:</strong> {{ accountData.clientAccountNo }}</span
>
<span *ngIf="panNumber && panNumber !== 'Not Available'">
&middot; <strong>PAN:</strong> {{ panNumber }}</span
>
</div>
<div class="client-address" *ngIf="clientAddress">
{{ clientAddress }}
</div>
</div>
</div>
<!-- Details Grid -->
<table class="certificate-details-table">
<tbody>
<tr>
<td class="lbl-col">Product</td>
<td class="val-col">{{ accountData.depositProductName }}</td>
<td class="lbl-col">{{ accountType === 'FD' ? 'Deposit Date' : 'First Deposit Date' }}</td>
<td class="val-col">{{ depositDate | dateFormat }}</td>
</tr>
<tr *ngIf="accountType === 'FD'">
<td class="lbl-col">Deposit Amount</td>
<td class="val-col">
{{ accountData.depositAmount | currency: accountData.currency.code : 'symbol-narrow' }}
</td>
<td class="lbl-col">Tenure</td>
<td class="val-col">{{ tenureLabel }}</td>
</tr>
<tr *ngIf="accountType === 'RD'">
<td class="lbl-col">Installment Amount</td>
<td class="val-col">
{{
accountData.mandatoryRecommendedDepositAmount | currency: accountData.currency.code : 'symbol-narrow'
}}
</td>
<td class="lbl-col">Installment Frequency</td>
<td class="val-col">
Every {{ accountData.recurringFrequency }} {{ accountData.recurringFrequencyType?.value }}
</td>
</tr>
<tr *ngIf="accountType === 'RD'">
<td class="lbl-col">Tenure</td>
<td class="val-col">{{ tenureLabel }}</td>
<td class="lbl-col">Interest Rate (p.a.)</td>
<td class="val-col">{{ accountData.nominalAnnualInterestRate }}%</td>
</tr>
<tr *ngIf="accountType === 'FD'">
<td class="lbl-col">Interest Rate (p.a.)</td>
<td class="val-col">{{ accountData.nominalAnnualInterestRate }}%</td>
<td class="lbl-col">Interest Compounding</td>
<td class="val-col">{{ accountData.interestCompoundingPeriodType?.value }}</td>
</tr>
<tr>
<td class="lbl-col">Maturity Date</td>
<td class="val-col">{{ accountData.maturityDate | dateFormat }}</td>
<td class="lbl-col">Current Balance</td>
<td class="val-col">
{{ accountData.summary?.accountBalance | currency: accountData.currency.code : 'symbol-narrow' }}
</td>
</tr>
<tr class="highlight-row">
<td colspan="2" class="lbl-col-highlight">MATURITY AMOUNT</td>
<td colspan="2" class="val-col-highlight">
{{ accountData.maturityAmount | currency: accountData.currency.code : 'symbol-narrow' }}
</td>
</tr>
</tbody>
</table>
<!-- Renewal History -- one row per term of this FD, oldest first. Deliberately shows only the
term number, not each term's internal account number: the customer holds one FD under one
number, and listing the per-term account numbers here would read as several deposits. Staff
can still see them on the account view's own renewal-history card. -->
<table class="certificate-details-table renewal-history-table" *ngIf="renewalHistory.length">
<thead>
<tr>
<th colspan="7" class="renewal-history-title">RENEWAL HISTORY</th>
</tr>
<tr>
<th>Term</th>
<th>From</th>
<th>To</th>
<th>Rate</th>
<th>Principal</th>
<th>Interest</th>
<th>Maturity Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let term of renewalHistory" [class.current-term-row]="term.accountid === accountData.id">
<td>{{ term.renewalcycleno === 0 ? 'Original' : 'Renewal ' + term.renewalcycleno }}</td>
<td>{{ term.activatedondate | dateFormat }}</td>
<td>{{ term.maturitydate | dateFormat }}</td>
<td>{{ term.interestrate }}%</td>
<td>{{ term.depositamount | currency: accountData.currency.code : 'symbol-narrow' }}</td>
<td>
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: accountData.currency.code : 'symbol-narrow')
}}
</td>
<td>{{ maturityFor(term) | currency: accountData.currency.code : 'symbol-narrow' }}</td>
</tr>
</tbody>
</table>
<div class="cert-footer">
<div class="seal-container">
<div class="seal-ring">
<div class="seal-text">COMMON SEAL</div>
</div>
</div>
<div class="closing-text">
Given under the common seal of the bank this {{ issueDate | date: 'd' }} day of
{{ issueDate | date: 'MMMM yyyy' }}.
</div>
<div class="signatures-container">
<div class="signature-block">
<div class="signature-line"></div>
<div class="signatory-title">Authorised Signatory</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<mat-dialog-actions align="end" class="no-print">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="printCertificate()">
<mat-icon class="m-r-5">print</mat-icon>
Print Certificate
</button>
</mat-dialog-actions>
@@ -0,0 +1,556 @@
/**
* 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/.
*/
.dialog-header {
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 12px;
margin-bottom: 16px;
font-weight: 500;
color: #1a252c;
}
.dialog-content-container {
max-height: 80vh;
overflow-y: auto;
padding: 10px;
}
/* Certificate Outer Container */
.certificate-container {
background-color: #faf8f5;
padding: 20px;
font-family: Georgia, 'Times New Roman', Times, serif;
color: #2c2520;
box-shadow: 0 4px 20px rgb(0 0 0 / 8%);
border-radius: 8px;
max-width: 800px;
margin: 0 auto;
}
/* Classic Ornate Border System */
.certificate-border-outer {
border: 5px double #c5a059; /* Gold double border */
padding: 8px;
background-color: #fdfcf7; /* Textured light cream */
border-radius: 4px;
position: relative;
}
.certificate-border-inner {
border: 1px solid #dcd1bd;
padding: 30px;
border-radius: 2px;
position: relative;
overflow: hidden;
}
/* Watermark */
.certificate-watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.03;
pointer-events: none;
z-index: 1;
.watermark-icon {
font-size: 280px;
width: 280px;
height: 280px;
color: #c5a059;
}
}
.certificate-header,
.cert-meta-row,
.cert-body-section,
.certificate-details-table,
.cert-footer {
position: relative;
z-index: 2; /* Ensure content is above watermark */
}
/* Header styling */
.certificate-header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #f2edd5;
padding-bottom: 20px;
.bank-name {
font-size: 26px;
font-weight: bold;
color: #0b3c5d; /* Dark Premium Blue */
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 4px;
}
.bank-sub {
font-size: 13px;
color: #7b6f63;
font-style: italic;
letter-spacing: 1px;
margin-bottom: 20px;
}
.cert-title {
font-size: 28px;
font-weight: 400;
color: #c5a059; /* Classic Gold */
letter-spacing: 4px;
margin-bottom: 5px;
font-family: Georgia, serif;
}
.cert-subtitle {
font-size: 11px;
color: #7b6f63;
text-transform: uppercase;
letter-spacing: 2px;
}
}
/* Meta Data Grid (Top of cert) */
.cert-meta-row {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
border-bottom: 1px solid #f2edd5;
padding-bottom: 15px;
.meta-item {
display: flex;
flex-direction: column;
.meta-label {
font-size: 11px;
text-transform: uppercase;
color: #928374;
letter-spacing: 1px;
margin-bottom: 3px;
}
.meta-value {
font-size: 15px;
font-weight: bold;
color: #322f2b;
}
}
}
/* Main Body text */
.cert-body-section {
margin-bottom: 35px;
.cert-main-text {
font-size: 15px;
line-height: 1.8;
text-align: center;
color: #4a453f;
margin-bottom: 25px;
.highlight-text {
color: #1a202c;
font-weight: 600;
border-bottom: 1px dotted #c5a059;
padding-bottom: 1px;
}
}
}
/* Issued To Details Block */
.issued-to-details {
border: 1px solid #f0e6d2;
background-color: #fbfaf5;
padding: 15px 20px;
border-radius: 4px;
margin: 20px auto;
max-width: 90%;
text-align: left;
.issued-to-title {
font-size: 10px;
font-weight: bold;
color: #c5a059;
letter-spacing: 1.5px;
margin-bottom: 6px;
}
.client-name-details {
font-size: 18px;
font-weight: bold;
color: #0b3c5d;
margin-bottom: 4px;
}
.client-ids {
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.client-address {
font-size: 12px;
color: #7b6f63;
line-height: 1.4;
}
}
/* Certificate Data Table */
.certificate-details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 40px;
td {
padding: 10px 12px;
border: 1px solid #ede8db;
font-size: 13px;
}
.lbl-col {
background-color: #f7f5ee;
font-weight: bold;
color: #7b6f63;
width: 25%;
}
.val-col {
color: #322f2b;
width: 25%;
}
.highlight-row {
background-color: #f5f2e9;
border-top: 2px solid #c5a059;
}
.lbl-col-highlight {
font-weight: bold;
color: #0b3c5d;
font-size: 14px;
letter-spacing: 1px;
text-align: right;
padding-right: 20px;
}
.val-col-highlight {
font-size: 16px;
font-weight: bold;
color: #b8860b; /* Dark Goldenrod */
}
}
/* Renewal History Table -- lists each earlier term this FD was renewed from */
.renewal-history-table {
th {
padding: 8px 10px;
border: 1px solid #ede8db;
font-size: 11px;
text-align: left;
background-color: #f7f5ee;
color: #7b6f63;
}
.renewal-history-title {
text-align: center;
background-color: #f5f2e9;
color: #0b3c5d;
font-weight: bold;
letter-spacing: 1.5px;
font-size: 12px;
}
.current-term-row {
background-color: #fbfaf5;
font-weight: bold;
}
}
/* Certificate Footer Seal / Signatures */
.cert-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
.seal-container {
width: 25%;
.seal-ring {
width: 90px;
height: 90px;
border: 2px dashed #b8860b;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
transform: rotate(-15deg);
.seal-text {
font-size: 10px;
font-weight: bold;
color: #b8860b;
text-align: center;
line-height: 1.2;
letter-spacing: 1px;
}
}
}
.closing-text {
width: 45%;
font-size: 12px;
color: #7b6f63;
font-style: italic;
line-height: 1.5;
text-align: center;
}
.signatures-container {
width: 30%;
text-align: center;
.signature-block {
display: inline-block;
width: 80%;
.signature-line {
border-bottom: 1px solid #7b6f63;
margin-bottom: 8px;
height: 40px; /* Space for signature */
}
.signatory-title {
font-size: 12px;
color: #7b6f63;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
}
/* Print CSS */
@media print {
/* Everything below `::ng-deep` reaches outside this component's own template -- html/body and
the whole Angular Material CDK overlay chain never carry this component's scoping attribute
under the default emulated encapsulation, so a plain (scoped) selector would silently never
match any of them. `::ng-deep` is deprecated but still the correct tool for a component that
genuinely needs to override its host application's shell during print. */
/* The app shell pins html/body to a fixed viewport height with overflow hidden (its inner
panels handle their own scrolling on screen) -- left in place, that caps every printed page
at one viewport's worth of content and silently cuts off the rest instead of paginating. */
::ng-deep html,
::ng-deep body {
height: auto !important;
overflow: visible !important;
}
/* Hide non-overlay contents during print */
::ng-deep body > :not(.cdk-overlay-container) {
display: none !important;
}
/* The dialog's own scroll region clips anything past 80vh -- printing only captures what a
scrollable/max-height box shows on screen, so both constraints must be lifted or the
certificate gets cut off after its first visible slice. */
.dialog-content-container {
max-height: none !important;
overflow: visible !important;
}
/* CDK's overlay wrapper/backdrop/pane are all `position: fixed` (needed to center the dialog
on screen) and the wrapper flex-centers its content -- during print, a fixed-position
ancestor is clipped to one page's height, so anything taller than that gets cut off and only
a thin cross-section (here, just the certificate's own border) survives. Every ancestor in
that chain has to become a normal, statically-flowing block for the full certificate to print. */
::ng-deep .cdk-overlay-backdrop {
display: none !important;
}
::ng-deep .cdk-overlay-container,
::ng-deep .cdk-global-overlay-wrapper,
::ng-deep .cdk-overlay-pane,
::ng-deep .mat-mdc-dialog-container,
::ng-deep .mdc-dialog__container,
::ng-deep .mdc-dialog__surface {
position: static !important;
display: block !important;
max-height: none !important;
max-width: none !important;
height: auto !important;
width: auto !important;
overflow: visible !important;
transform: none !important;
}
/* Make certificate fill page */
#deposit-certificate-print {
position: static !important;
width: 100% !important;
height: auto !important;
padding: 0 !important;
margin: 0 !important;
background-color: #fff !important;
box-shadow: none !important;
}
.certificate-border-outer {
border-width: 8px !important;
background-color: #fff !important;
}
.no-print {
display: none !important;
}
::ng-deep .mat-mdc-dialog-container {
padding: 0 !important;
}
/* Compact every spacing/font value so the whole certificate fits a single printed page --
the on-screen sizing is generous by design, but that same height doesn't fit one page. */
.certificate-container {
padding: 6px !important;
}
.certificate-border-inner {
padding: 14px !important;
}
.certificate-header {
margin-bottom: 10px !important;
padding-bottom: 10px !important;
.bank-name {
font-size: 19px !important;
margin-bottom: 2px !important;
}
.bank-sub {
font-size: 10px !important;
margin-bottom: 8px !important;
}
.cert-title {
font-size: 19px !important;
letter-spacing: 2px !important;
margin-bottom: 2px !important;
}
.cert-subtitle {
font-size: 9px !important;
}
}
.cert-meta-row {
margin-bottom: 10px !important;
padding-bottom: 8px !important;
.meta-label {
font-size: 9px !important;
margin-bottom: 1px !important;
}
.meta-value {
font-size: 12px !important;
}
}
.cert-body-section {
margin-bottom: 10px !important;
.cert-main-text {
font-size: 11px !important;
line-height: 1.4 !important;
margin-bottom: 10px !important;
}
}
.issued-to-details {
padding: 8px 14px !important;
margin: 8px auto !important;
.client-name-details {
font-size: 14px !important;
}
.client-ids,
.client-address {
font-size: 10px !important;
}
}
.certificate-details-table {
margin-bottom: 12px !important;
td {
padding: 5px 10px !important;
font-size: 10px !important;
}
.lbl-col-highlight {
font-size: 12px !important;
}
.val-col-highlight {
font-size: 13px !important;
}
}
.renewal-history-table {
margin-bottom: 12px !important;
th {
padding: 4px 8px !important;
font-size: 8px !important;
}
td {
padding: 4px 8px !important;
font-size: 9px !important;
}
.renewal-history-title {
font-size: 10px !important;
}
}
.cert-footer {
margin-top: 8px !important;
.seal-ring {
width: 60px !important;
height: 60px !important;
}
.seal-text {
font-size: 8px !important;
}
.closing-text {
font-size: 10px !important;
}
.signature-line {
height: 22px !important;
}
.signatory-title {
font-size: 10px !important;
}
}
}
@@ -0,0 +1,157 @@
/**
* 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 {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { ClientsService } from 'app/clients/clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { CurrencyPipe } from '@angular/common';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
/**
* Printable deposit certificate for a Fixed Deposit or Recurring Deposit account -- handed to the
* customer as physical proof of their deposit. Shared between FD and RD since both account shapes
* carry the same core fields (depositAmount, nominalAnnualInterestRate, depositPeriod, maturityDate,
* maturityAmount); RD additionally shows its installment/frequency details.
*/
@Component({
selector: 'mifosx-deposit-certificate-dialog',
templateUrl: './deposit-certificate-dialog.component.html',
styleUrls: ['./deposit-certificate-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
DateFormatPipe,
CurrencyPipe,
MatButton,
MatIcon
]
})
export class DepositCertificateDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<DepositCertificateDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
clientsService = inject(ClientsService);
/** 'FD' or 'RD' -- controls the title and which extra rows show. */
accountType: 'FD' | 'RD';
accountData: any;
clientData: any;
clientAddress = '';
panNumber = 'Not Available';
certificateNo = '';
issueDate: Date = new Date();
/**
* Every term of this FD, oldest first, as resolved by the backend -- a renewal opens each term as
* its own account, so the certificate lists them all to show the deposit's full history. Empty for
* an FD that has never been renewed. Keys are lower-cased, arriving from SQL column labels.
*/
renewalHistory: any[] = [];
/**
* How many times the deposit has actually rolled over. {@link renewalHistory} lists every TERM, and the
* first of those is the original deposit rather than a renewal, so the count is one less than the number
* of rows. Stated on a customer-facing certificate, so an off-by-one here misreports the deposit.
*/
get renewalCount(): number {
return Math.max(this.renewalHistory.length - 1, 0);
}
ngOnInit() {
this.accountType = this.data.accountType;
this.accountData = this.data.accountData;
const clientId = this.data.clientId != null ? String(this.data.clientId) : null;
this.certificateNo = `${this.accountType}-CERT-${this.accountData.fdAccountNumber || this.accountData.accountNo}`;
this.renewalHistory = this.accountData?.renewalHistory ?? [];
if (!clientId) {
return;
}
this.clientsService.getClientData(clientId).subscribe({
next: (client: any) => {
this.clientData = client;
},
error: () => {}
});
this.clientsService.getClientAddressData(clientId).subscribe({
next: (addresses: any[]) => {
if (addresses && addresses.length > 0) {
const permanent = addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0];
this.clientAddress = this.formatAddress(permanent);
}
},
error: () => {}
});
this.clientsService.getClientIdentifiers(clientId).subscribe({
next: (identifiers: any[]) => {
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
const panId = idList.find((id: any) => /pan/i.test(id?.documentType?.name || ''));
if (panId) {
this.panNumber = panId.documentKey;
}
},
error: () => {}
});
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
formatAddress(addr: any): string {
if (!addr) return '';
return [
addr.addressLine1,
addr.addressLine2,
addr.townVillage,
addr.city,
addr.stateName,
addr.countryName,
addr.postalCode
]
.filter((p) => !!p && String(p).trim())
.join(', ');
}
get depositDate(): any {
return this.accountData?.timeline?.activatedOnDate ?? this.accountData?.timeline?.submittedOnDate ?? null;
}
get tenureLabel(): string {
const period = this.accountData?.depositPeriod;
const freq = this.accountData?.depositPeriodFrequency?.value;
if (period == null) return 'N/A';
return freq ? `${period} ${freq}` : String(period);
}
printCertificate() {
window.print();
}
}
@@ -49,8 +49,13 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="viewDiff(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
<button
mat-stroked-button
class="psp-btn psp-btn-view"
[disabled]="loadingDiffFor === edit"
(click)="viewDiff(edit)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>{{ loadingDiffFor === edit ? 'Loading…' : 'Compare' }}
</button>
<button mat-stroked-button class="psp-btn psp-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
@@ -65,37 +70,6 @@
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
@if (selectedEdit) {
<div class="psp-diff-panel">
<div class="psp-diff-header">
<h3>Comparing "{{ selectedEdit.savingsProductName }}" &mdash; Original vs Proposed</h3>
<button mat-icon-button (click)="closeDiff()"><fa-icon icon="times"></fa-icon></button>
</div>
@if (diffRows.length === 0) {
<p class="psp-diff-loading">Loading comparison&hellip;</p>
} @else {
<table class="psp-diff-table">
<thead>
<tr>
<th>Field</th>
<th>Original</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of diffRows; track row.label) {
<tr [class.psp-diff-row--changed]="row.changed">
<td class="psp-diff-label">{{ row.label }}</td>
<td class="psp-diff-original">{{ row.original }}</td>
<td class="psp-diff-proposed">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
}
@if (!showData) {
@@ -87,64 +87,6 @@ $text-muted: #64748b;
}
}
.psp-diff-panel {
margin-top: 20px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.psp-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
}
.psp-diff-loading {
color: $text-muted;
font-size: 13px;
}
.psp-diff-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
th {
color: $text-muted;
font-weight: 600;
}
}
.psp-diff-row--changed {
background: #fffbeb;
.psp-diff-original {
color: #b91c1c;
text-decoration: line-through;
}
.psp-diff-proposed {
color: #15803d;
font-weight: 600;
}
}
.empty-state {
display: flex;
flex-direction: column;
@@ -23,10 +23,10 @@ import {
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ProductsService } from 'app/products/products.service';
import { ProductEditDiffDialogComponent } from './product-edit-diff-dialog/product-edit-diff-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
@@ -50,8 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
MatIcon
]
})
export class PendingSavingsProductsComponent {
@@ -72,9 +71,8 @@ export class PendingSavingsProductsComponent {
'actions'
];
selectedEdit: any = null;
originalProduct: any = null;
diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = [];
/** Set only while the original product is being fetched, so the button can show progress. */
loadingDiffFor: any = null;
constructor() {
this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => {
@@ -96,18 +94,23 @@ export class PendingSavingsProductsComponent {
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
}
/**
* Fetched with template=true so the response carries the option lists (accountingRuleOptions,
* interestCompoundingPeriodTypeOptions, ...) alongside the product. The submitted edit stores enum
* fields as bare ids, so the diff needs those lists to show a readable name instead of a raw number.
*/
private getOriginalProduct$(edit: any) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.getCurrentProduct(edit.savingsProductId);
return this.productsService.getCurrentProduct(edit.savingsProductId, true);
case 'SHARE':
return this.productsService.getShareProduct(edit.savingsProductId);
return this.productsService.getShareProduct(edit.savingsProductId, true);
case 'FD':
return this.productsService.getFixedDepositProduct(edit.savingsProductId);
return this.productsService.getFixedDepositProductAndTemplate(edit.savingsProductId);
case 'RD':
return this.productsService.getRecurringDepositProduct(edit.savingsProductId);
return this.productsService.getRecurringDepositProductAndTemplate(edit.savingsProductId);
default:
return this.productsService.getSavingProduct(edit.savingsProductId);
return this.productsService.getSavingProduct(edit.savingsProductId, true);
}
}
@@ -142,29 +145,27 @@ export class PendingSavingsProductsComponent {
}
viewDiff(edit: any): void {
this.selectedEdit = edit;
this.diffRows = [];
this.getOriginalProduct$(edit).subscribe((original: any) => {
this.originalProduct = original;
const proposed = edit.proposedChanges || {};
const keys = Object.keys(proposed);
this.diffRows = keys.map((key) => {
const originalValue = original ? original[key] : undefined;
const proposedValue = proposed[key];
return {
label: key,
original: originalValue,
proposed: proposedValue,
changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue)
};
});
this.loadingDiffFor = edit;
this.getOriginalProduct$(edit).subscribe({
next: (original: any) => {
this.loadingDiffFor = null;
this.dialog.open(ProductEditDiffDialogComponent, {
width: '820px',
data: {
productName: edit.savingsProductName,
original: original ?? {},
proposed: edit.proposedChanges ?? {}
}
});
},
error: () => {
this.loadingDiffFor = null;
}
});
}
closeDiff(): void {
this.selectedEdit = null;
this.originalProduct = null;
this.diffRows = [];
this.loadingDiffFor = null;
}
approve(edit: any): void {
@@ -0,0 +1,53 @@
<!--
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/.
-->
<h2 mat-dialog-title class="ped-title">
<mat-icon class="ped-title__icon">difference</mat-icon>
<span>
Proposed changes &mdash; <strong>{{ productName }}</strong>
</span>
</h2>
<div mat-dialog-content class="ped-content">
@if (rows.length === 0) {
<div class="ped-empty">
<mat-icon class="ped-empty__icon">check_circle</mat-icon>
<p class="ped-empty__title">No changes to review</p>
<p class="ped-empty__text">
This edit was submitted without changing any product setting &mdash; every submitted value matches the current
product.
</p>
</div>
} @else {
<p class="ped-summary">
<strong>{{ rows.length }}</strong> field{{ rows.length === 1 ? '' : 's' }} changed. Unchanged fields are hidden.
</p>
<table class="ped-table">
<thead>
<tr>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.label) {
<tr>
<td class="ped-table__label">{{ row.label }}</td>
<td class="ped-table__old">{{ row.original }}</td>
<td class="ped-table__new">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
</div>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</mat-dialog-actions>
@@ -0,0 +1,115 @@
/**
* 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/.
*/
$border: #e2e8f0;
$muted: #64748b;
.ped-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #0f172a;
border-bottom: 1px solid $border;
padding-bottom: 12px;
margin-bottom: 0;
&__icon {
color: #0ea5e9;
}
}
.ped-content {
max-height: 70vh;
overflow-y: auto;
padding-top: 16px;
}
.ped-summary {
margin: 0 0 14px;
font-size: 12.5px;
color: $muted;
}
.ped-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 9px 12px;
border-bottom: 1px solid $border;
vertical-align: top;
}
th {
color: $muted;
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.4px;
background: #f8fafc;
position: sticky;
top: 0;
}
tbody tr:hover {
background: #fffbeb;
}
&__label {
font-weight: 600;
color: #1e293b;
width: 34%;
}
&__old {
color: #b91c1c;
text-decoration: line-through;
width: 33%;
}
&__new {
color: #15803d;
font-weight: 600;
width: 33%;
}
}
.ped-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 32px 16px;
&__icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #16a34a;
margin-bottom: 10px;
}
&__title {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
&__text {
margin: 0;
font-size: 12.5px;
color: $muted;
max-width: 420px;
}
}
@@ -0,0 +1,261 @@
/**
* 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 {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface DiffRow {
label: string;
original: string;
proposed: string;
}
/**
* Shows what a pending product edit actually changes: only the fields whose value differs, with the
* original and proposed values side by side. The submitted payload carries every field the edit form
* posted, most of them unchanged, so listing all of them buries the handful a checker needs to look at.
*/
@Component({
selector: 'mifosx-product-edit-diff-dialog',
templateUrl: './product-edit-diff-dialog.component.html',
styleUrls: ['./product-edit-diff-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
MatIcon
]
})
export class ProductEditDiffDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ProductEditDiffDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
productName = '';
rows: DiffRow[] = [];
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */
private static readonly IGNORED_KEYS = new Set([
'locale',
'dateFormat',
'monthDayFormat',
'id',
'productId',
'savingsProductId'
]);
/**
* Payload fields the stored product exposes under a different path, so the "current" side has
* something to compare against instead of always reading as newly set.
*/
private static readonly FIELD_ALIASES: Record<string, string> = {
currencyCode: 'currency.code',
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id'
};
/**
* GL-account fields, keyed by their payload name, mapped to where the stored product keeps the
* expanded account under `accountingMappings`. Their option lists live pooled under
* `accountingMappingOptions` rather than a per-field `<field>Options` key, so both sides of the
* diff need this table to turn an account id into the account's name.
*/
private static readonly GL_FIELD_TO_MAPPING: Record<string, string> = {
savingsReferenceAccountId: 'savingsReferenceAccount',
savingsControlAccountId: 'savingsControlAccount',
transfersInSuspenseAccountId: 'transfersInSuspenseAccount',
interestOnSavingsAccountId: 'interestOnSavingsAccount',
incomeFromFeeAccountId: 'incomeFromFeeAccount',
incomeFromPenaltyAccountId: 'incomeFromPenaltyAccount',
incomeFromInterestId: 'incomeFromInterest',
overdraftPortfolioControlId: 'overdraftPortfolioControl',
writeOffAccountId: 'writeOffAccount',
feesReceivableAccountId: 'feeReceivableAccount',
penaltiesReceivableAccountId: 'penaltyReceivableAccount',
interestPayableAccountId: 'interestPayableAccount',
interestReceivableAccountId: 'interestReceivableAccount',
escheatLiabilityId: 'escheatLiabilityAccount'
};
private glAccountNamesById = new Map<string, string>();
ngOnInit(): void {
this.productName = this.data.productName ?? '';
const original = this.data.original ?? {};
const proposed = this.data.proposed ?? {};
this.buildGlAccountLookup(original);
this.rows = Object.keys(proposed)
.filter((key) => !ProductEditDiffDialogComponent.IGNORED_KEYS.has(key))
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !this.sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: this.humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Pools every GL account the template offers so an account id can be shown as its name. */
private buildGlAccountLookup(template: any): void {
const optionGroups = template?.accountingMappingOptions ?? {};
for (const group of Object.values<any>(optionGroups)) {
if (!Array.isArray(group)) {
continue;
}
for (const account of group) {
if (account?.id === undefined) {
continue;
}
const label = account.name ?? account.glCode ?? null;
if (label) {
this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label);
}
}
}
}
/** Reads the current value for a payload field, following an alias or GL mapping when needed. */
private originalValueFor(key: string, original: any): any {
const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];
if (glMappingKey) {
return original?.accountingMappings?.[glMappingKey] ?? null;
}
const alias = ProductEditDiffDialogComponent.FIELD_ALIASES[key];
if (alias) {
return alias.split('.').reduce((acc: any, part: string) => (acc == null ? acc : acc[part]), original);
}
return original?.[key];
}
/**
* Enum/option fields arrive from the edit as a bare id, which reads as a meaningless number in the
* table. The product fetched with template=true carries the matching option list under a predictable
* `<field>Options` key (accountingRule -> accountingRuleOptions), so the id is resolved back to the
* label the maker actually picked. Returns null when there is no such list to resolve against.
*/
private resolveOptionLabel(fieldKey: string, id: any, template: any): string | null {
if (ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[fieldKey]) {
return this.glAccountNamesById.get(String(id)) ?? null;
}
const options = template?.[`${fieldKey}Options`];
if (!Array.isArray(options)) {
return null;
}
const match = options.find((option: any) => String(option?.id) === String(id));
if (!match) {
return null;
}
return match.value ?? match.name ?? match.code ?? null;
}
/**
* The stored product returns enum/option fields as objects ({id, value}) while the submitted payload
* carries just the id, so a raw comparison reports every such field as changed. Comparing on the id
* (and coercing numeric strings) keeps the list to fields whose value genuinely moved.
*/
private sameValue(a: any, b: any): boolean {
return JSON.stringify(this.normalize(a)) === JSON.stringify(this.normalize(b));
}
private normalize(value: any): any {
if (value === null || value === undefined || value === '') {
return null;
}
// Dates come back from the product as [yyyy, m, d] but are submitted as "yyyy-MM-dd"; without
// folding both to one form every date field reads as changed on every edit.
const asDate = this.toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
// An empty array and an absent value mean the same thing here.
return value.length === 0 ? null : value.map((item) => this.normalize(item));
}
if (typeof value === 'object') {
if (value.id !== undefined) {
return this.normalize(value.id);
}
return Object.keys(value).length === 0 ? null : JSON.stringify(value);
}
if (typeof value === 'boolean') {
return value;
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
/** Recognises both date shapes the API uses and returns "yyyy-MM-dd", or null if not a date. */
private toIsoDate(value: any): string | null {
if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) {
const [
year,
month,
day
] = value;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
return null;
}
private displayValue(value: any, fieldKey: string, template: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const asDate = this.toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`;
}
if (typeof value === 'object') {
if (Object.keys(value).length === 0) {
return '—';
}
// An expanded GL account -- shown the same way as the resolved id on the other side.
if (value.name && value.glCode) {
return `${value.name} (${value.glCode})`;
}
// Already an expanded {id, value} option from the stored product.
return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—');
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
// A bare id that names an option -- show the option's label, not the number.
return this.resolveOptionLabel(fieldKey, value, template) ?? String(value);
}
/** camelCase / PascalCase field name to a spaced, capitalised label. */
private humanizeKey(key: string): string {
const spaced = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
}