From ff781e50e3112cf3a94352180e09f8452383040f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:39:52 +0530 Subject: [PATCH 1/5] feat(savings,deposits,current-accounts): streamline account activation flow Matches the backend change that stopped auto-depositing money at activation: removes the opening-balance field from every Activate screen (Savings, Current, FD, RD) since funding now happens as a separate deposit afterward, and removes the Digital-Facility step from every Create wizard since every facility starts disabled by default and gets turned on later through Edit Nominee & Features. Also merges Approve+Activate into a single action for Savings, Current, FD and RD accounts -- one click takes the account straight to Active instead of two separate screens. --- .../create-current-account.component.html | 33 ++----------------- .../create-current-account.component.ts | 4 +-- .../activate-current-account.component.html | 12 ------- .../activate-current-account.component.ts | 12 +------ .../approve-current-account.component.html | 1 + .../approve-current-account.component.ts | 16 +++++++-- .../current-account-actions.component.html | 4 +-- ...urrent-account-digital-step.component.html | 1 - .../current-account-digital-step.component.ts | 9 +++-- .../current-account-preview-step.component.ts | 9 +++-- ...rove-fixed-deposits-account.component.html | 1 + ...pprove-fixed-deposits-account.component.ts | 16 +++++++-- ...-recurring-deposits-account.component.html | 1 + ...ve-recurring-deposits-account.component.ts | 16 +++++++-- .../create-savings-account.component.html | 32 ++---------------- .../create-savings-account.component.ts | 4 +-- .../activate-savings-account.component.html | 16 ++------- .../activate-savings-account.component.ts | 15 +-------- .../approve-savings-account.component.html | 1 + .../approve-savings-account.component.ts | 14 ++++++-- .../saving-account-actions.component.html | 4 +-- ...avings-account-digital-step.component.html | 1 - .../savings-account-digital-step.component.ts | 9 +++-- .../savings-account-preview-step.component.ts | 10 +++--- 24 files changed, 85 insertions(+), 156 deletions(-) diff --git a/src/app/current-accounts/create-current-account/create-current-account.component.html b/src/app/current-accounts/create-current-account/create-current-account.component.html index 900db006a..7a79e519a 100644 --- a/src/app/current-accounts/create-current-account/create-current-account.component.html +++ b/src/app/current-accounts/create-current-account/create-current-account.component.html @@ -12,9 +12,7 @@

Create Current Account

-
- Pick a product, confirm currency, set digital channels and review before submit. -
+
Pick a product, confirm currency and review before submit.
@@ -78,34 +76,7 @@ - - - - - Digital - -
- @if (currentAccountProductTemplate) { - - } - -
- - - -
-
-
- - + diff --git a/src/app/current-accounts/create-current-account/create-current-account.component.ts b/src/app/current-accounts/create-current-account/create-current-account.component.ts index 9aa74d548..6c682a919 100644 --- a/src/app/current-accounts/create-current-account/create-current-account.component.ts +++ b/src/app/current-accounts/create-current-account/create-current-account.component.ts @@ -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; diff --git a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html index 7b9e69031..19abc5fdc 100644 --- a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html +++ b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html @@ -30,18 +30,6 @@ } - - - Opening Balance - - ₹  - @if (minRequiredOpeningBalance) { - Minimum required: ₹{{ minRequiredOpeningBalance }} - } - @if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) { - Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }} - } - diff --git a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts index 98701b637..ae85c6d92 100644 --- a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts @@ -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) - ] ] }); } diff --git a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html index 78322c36e..cfb7b6a87 100644 --- a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html +++ b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html @@ -10,6 +10,7 @@
+

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts index ca8791c73..f71db9650 100644 --- a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts @@ -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 }); + }); }); } } diff --git a/src/app/current-accounts/current-account-actions/current-account-actions.component.html b/src/app/current-accounts/current-account-actions/current-account-actions.component.html index b89775310..b1442a445 100644 --- a/src/app/current-accounts/current-account-actions/current-account-actions.component.html +++ b/src/app/current-accounts/current-account-actions/current-account-actions.component.html @@ -13,9 +13,7 @@ } @if (actions['Activate']) { - + } @if (actions['Undo Approval']) { diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html index ae448a85a..f13275c06 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html @@ -15,7 +15,6 @@

Digital & Account Features

Toggle the facilities available for this account (as per RBI guidelines)

- FROM PRODUCT
diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts index 6e5cc09d5..bcfb111da 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts @@ -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 } }; diff --git a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts index dbddadaa1..0683c968d 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts @@ -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 { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html index 81998d8c9..5547c958e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts index a7ae6c9b2..a517f98b5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts @@ -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 }); + }); }); } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html index 381399099..1519ff51f 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts index 8dc8c427c..847aa80e5 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts @@ -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 }); + }); }); } } diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html index 7bdf5abed..dfbf67e7c 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.html +++ b/src/app/savings/create-savings-account/create-savings-account.component.html @@ -12,9 +12,7 @@

Create Savings Account

-
- Pick a product, confirm currency, set digital channels and review before submit. -
+
Pick a product, confirm currency and review before submit.
@@ -76,33 +74,7 @@
- - - - - Digital - -
- @if (savingsAccountProductTemplate) { - - - } - -
- - - -
-
-
- - + diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index e5d7c2989..2ab7e094e 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -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; diff --git a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html index c38a3f304..3d92c8391 100644 --- a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html +++ b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html @@ -14,7 +14,9 @@

Activate Savings Account

-

Set the activation date and opening balance to bring this account live.

+

+ Set the activation date to bring this account live -- funding happens afterwards, as a separate deposit. +

@@ -45,18 +47,6 @@ } - - - Opening Balance - - ₹  - @if (minRequiredOpeningBalance) { - Minimum required: ₹{{ minRequiredOpeningBalance }} - } - @if (activateSavingsAccountForm.controls.openingBalance.hasError('min')) { - Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }} - } -
diff --git a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts index e9add4f43..d7a41c94a 100644 --- a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts @@ -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 }); }); diff --git a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html index 8f61d900d..b6d897fb1 100644 --- a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html +++ b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts index 167f6cb2c..d1654136d 100644 --- a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts @@ -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 }); + }); }); } } diff --git a/src/app/savings/saving-account-actions/saving-account-actions.component.html b/src/app/savings/saving-account-actions/saving-account-actions.component.html index 45d3df9ca..aaf4f6fc4 100644 --- a/src/app/savings/saving-account-actions/saving-account-actions.component.html +++ b/src/app/savings/saving-account-actions/saving-account-actions.component.html @@ -13,9 +13,7 @@ } @if (actions['Activate']) { - + } @if (actions['Undo Approval']) { diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html index 5b0970996..8229ab305 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html @@ -15,7 +15,6 @@

Digital & Account Features

Toggle the facilities available for this account (as per RBI guidelines)

- FROM PRODUCT
diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts index 30da85e32..6add60514 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts @@ -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 } }; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index f8837abfc..32b227bcd 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -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 { From b953afe2da8866e182bd43e8ca3bba237b01ba54 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:43:00 +0530 Subject: [PATCH 2/5] fix(clients): remove leftover unresolved git conflict markers on Last Name field An unresolved git stash conflict had been committed as-is, leaving literal <<<<<<< Updated upstream / ======= / >>>>>>> Stashed changes markers inside the template. Angular rendered them as plain text right in the create-client form. Kept the blur-triggered capitalizeControl() version (matches First/Middle Name) and dropped the now-unused formatName() left behind by the other side of the conflict. --- .../client-general-step.component.html | 4 ---- .../client-general-step.component.ts | 22 ------------------- 2 files changed, 26 deletions(-) diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index f5ad0590e..d892f9368 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -263,7 +263,6 @@ {{ 'labels.inputs.Last Name' | translate }} person - <<<<<<< Updated upstream - ======= - - >>>>>>> Stashed changes @if (createClientForm.controls.lastname.hasError('required')) { Last name is required } @else if (createClientForm.controls.lastname.hasError('pattern')) { diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 8ea5bf5e5..910651bb5 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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 }); - } - } } /** From e58fd3c79a82add03b6614d07b598506c0857008 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 20 Aug 2026 11:05:25 +0530 Subject: [PATCH 3/5] feat(deposits): sync linked account with maturity terms, add deposit certificate printing Fixed and Recurring Deposit wizards now bidirectionally sync the Product tab's linked-account selection with the new Terms tab's Transfer-to-Savings account, disable the Withdraw Deposit maturity instruction end-to-end, and card-style the Transfer to Savings picker to match the existing linked-account UI. Adds a printable FD/RD deposit certificate from the account view page, following the existing letter-mgmt certificate pattern. Co-Authored-By: Claude Sonnet 5 --- ...reate-fixed-deposit-account.component.html | 37 +- .../create-fixed-deposit-account.component.ts | 11 +- .../edit-fixed-deposit-account.component.html | 37 +- .../edit-fixed-deposit-account.component.ts | 9 +- ...-deposit-account-details-step.component.ts | 34 +- ...account-maturity-terms-step.component.html | 108 ++++ ...account-maturity-terms-step.component.scss | 152 ++++++ ...t-account-maturity-terms-step.component.ts | 117 ++++ ...eposit-account-preview-step.component.html | 35 ++ ...-deposit-account-preview-step.component.ts | 21 +- .../fixed-deposit-account-view.component.html | 10 + .../fixed-deposit-account-view.component.ts | 15 + ...-recurring-deposits-account.component.html | 37 +- ...te-recurring-deposits-account.component.ts | 11 +- ...t-recurring-deposit-account.component.html | 37 +- ...dit-recurring-deposit-account.component.ts | 11 +- ...deposits-account-details-step.component.ts | 35 +- ...account-maturity-terms-step.component.html | 108 ++++ ...account-maturity-terms-step.component.scss | 152 ++++++ ...s-account-maturity-terms-step.component.ts | 116 ++++ ...posits-account-preview-step.component.html | 35 ++ ...deposits-account-preview-step.component.ts | 24 +- ...eposits-account-settings-step.component.ts | 19 + ...rring-deposits-account-view.component.html | 13 + ...curring-deposits-account-view.component.ts | 15 + ...posit-product-settings-step.component.html | 27 + ...posit-product-settings-step.component.scss | 9 + ...deposit-product-settings-step.component.ts | 11 + ...posit-product-settings-step.component.html | 27 + ...posit-product-settings-step.component.scss | 9 + ...deposit-product-settings-step.component.ts | 8 + .../deposit-certificate-dialog.component.html | 160 ++++++ .../deposit-certificate-dialog.component.scss | 512 ++++++++++++++++++ .../deposit-certificate-dialog.component.ts | 132 +++++ 34 files changed, 2079 insertions(+), 15 deletions(-) create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts create mode 100644 src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html create mode 100644 src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss create mode 100644 src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts create mode 100644 src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html create mode 100644 src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss create mode 100644 src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts diff --git a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html index a0b81c891..d0ac260e9 100644 --- a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html +++ b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html @@ -33,6 +33,7 @@ @@ -107,7 +108,40 @@
- + + + + + Terms + +
+ @if (fixedDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -121,6 +155,7 @@ [fixedDepositAccountData]="fixedDepositAccount" [selectedTenure]="selectedTenure" [fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []" + [closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts index 99e4fcfef..d076cbf67 100644 --- a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts +++ b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts @@ -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, diff --git a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html index 8cbf3f2df..c7e98d743 100644 --- a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html +++ b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html @@ -35,6 +35,7 @@ @@ -111,7 +112,40 @@ - + + + + + Terms + +
+ @if (fixedDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -125,6 +159,7 @@ [fixedDepositAccountData]="fixedDepositAccount" [selectedTenure]="selectedTenure" [fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []" + [closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts index 7399b0efa..41c63cb0f 100644 --- a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts +++ b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts @@ -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, diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts index c4a42c210..7aa178b82 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts @@ -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; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html new file mode 100644 index 000000000..17d6f00d7 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html @@ -0,0 +1,108 @@ + + + +

{{ 'labels.heading.For Pre-mature closure' | translate }}

+ + + {{ 'labels.inputs.Maturity Instructions' | translate }} + + @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { + + {{ maturityInstruction.value | translateKey: 'catalogs' }} + + } + + + + @if (closureSettingsForm.contains('transferToSavingsId')) { +
+
+
+
+ savings +
+
+

{{ 'labels.inputs.Transfer to savings account' | translate }}

+ Maturity proceeds will be credited to this account +
+
+
+ +
+ @if (selectedTransferAccount) { +
+
+ + savings + Savings + +
+ {{ selectedTransferAccount.accountNo }} + {{ selectedTransferAccount.savingsProductName }} +
+ @if (selectedTransferAccount.summary?.accountBalance != null) { +
+ Balance + + {{ selectedTransferAccount.currency?.code }} + {{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }} + +
+ } +
+ +
+ } @else { + + {{ 'labels.inputs.Transfer to savings account' | translate }} + + @for (account of savingsAccountsData; track account) { + + ({{ account.accountNo }}) {{ account.savingsProductName }} + + } + + Transfer to savings account is required + + } +
+
+ } + + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (closureSettingsForm.value.preClosurePenalApplicable) { + + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + + } + diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss new file mode 100644 index 000000000..1f807bcce --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss @@ -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; + } + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts new file mode 100644 index 000000000..bce8468a1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts @@ -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; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html index 7abd9466a..98644bdb4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html @@ -683,6 +683,41 @@ +
+
+ + Maturity & Pre-mature Closure +
+
+
+
+ {{ 'labels.inputs.Maturity Instructions' | translate }} + {{ closureMaturityInstructionLabel }} +
+ @if (closureSettings?.transferToSavingsId) { +
+ {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ closureTransferToSavingsLabel }} +
+ } +
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + {{ closureSettings?.preClosurePenalApplicable | yesNo }} +
+ @if (closureSettings?.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%) + {{ closureSettings?.preClosurePenalInterest }}% +
+
+ {{ 'labels.inputs.Period' | translate }} + {{ closurePenalInterestOnTypeLabel }} +
+ } +
+
+
+ @if (!hideButtons) {
+ } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts index 7715b65c5..0e550afb3 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts @@ -16,6 +16,7 @@ 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 { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; /** Custom Button Config. */ import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config'; @@ -358,6 +359,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. diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html index d4f2d0b6c..7b55c02bd 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html @@ -33,6 +33,7 @@ @@ -111,7 +112,40 @@
- + + + + + Terms + +
+ @if (recurringDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -132,6 +166,7 @@ [rdDaysInYear]="rdDaysInYear" [rdAppliedRate]="rdAppliedRate" [rdIsSeniorCitizen]="rdIsSeniorCitizen" + [closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts index 8ab2a000c..d68fd0229 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts @@ -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, diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html index f3d7d5225..e03d3542d 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html @@ -35,6 +35,7 @@ @@ -115,7 +116,40 @@ - + + + + + Terms + +
+ @if (recurringDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -136,6 +170,7 @@ [rdDaysInYear]="rdDaysInYear" [rdAppliedRate]="rdAppliedRate" [rdIsSeniorCitizen]="rdIsSeniorCitizen" + [closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts index 4076b4661..7b5bb33f3 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts @@ -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, diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts index 6ba4bea37..f7bb9d68f 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts @@ -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; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html new file mode 100644 index 000000000..17d6f00d7 --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html @@ -0,0 +1,108 @@ + + +
+

{{ 'labels.heading.For Pre-mature closure' | translate }}

+ + + {{ 'labels.inputs.Maturity Instructions' | translate }} + + @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { + + {{ maturityInstruction.value | translateKey: 'catalogs' }} + + } + + + + @if (closureSettingsForm.contains('transferToSavingsId')) { +
+
+
+
+ savings +
+
+

{{ 'labels.inputs.Transfer to savings account' | translate }}

+ Maturity proceeds will be credited to this account +
+
+
+ +
+ @if (selectedTransferAccount) { +
+
+ + savings + Savings + +
+ {{ selectedTransferAccount.accountNo }} + {{ selectedTransferAccount.savingsProductName }} +
+ @if (selectedTransferAccount.summary?.accountBalance != null) { +
+ Balance + + {{ selectedTransferAccount.currency?.code }} + {{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }} + +
+ } +
+ +
+ } @else { + + {{ 'labels.inputs.Transfer to savings account' | translate }} + + @for (account of savingsAccountsData; track account) { + + ({{ account.accountNo }}) {{ account.savingsProductName }} + + } + + Transfer to savings account is required + + } +
+
+ } + + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (closureSettingsForm.value.preClosurePenalApplicable) { + + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + + } +
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss new file mode 100644 index 000000000..1f807bcce --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss @@ -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; + } + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts new file mode 100644 index 000000000..d32140665 --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts @@ -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; + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html index 2ff42a561..4abb78a1a 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html @@ -853,6 +853,41 @@ } +
+
+ + Maturity & Pre-mature Closure +
+
+
+
+ {{ 'labels.inputs.Maturity Instructions' | translate }} + {{ closureMaturityInstructionLabel }} +
+ @if (closureSettings?.transferToSavingsId) { +
+ {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ closureTransferToSavingsLabel }} +
+ } +
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + {{ closureSettings?.preClosurePenalApplicable | yesNo }} +
+ @if (closureSettings?.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%) + {{ closureSettings?.preClosurePenalInterest }}% +
+
+ {{ 'labels.inputs.Period' | translate }} + {{ closurePenalInterestOnTypeLabel }} +
+ } +
+
+
+ @if (!hideButtons) {
+ } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts index 24ca434e9..22d3aa3da 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts @@ -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. diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html index 7843f2654..201b48241 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html @@ -21,6 +21,33 @@ Lien Marked Apply Withdrawal Fee for Transfers
+ + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (fixedDepositProductSettingsForm.value.preClosurePenalApplicable) { +
+ + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for ( + preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; + track preClosurePenalInterestOnType + ) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + +
+ } diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss index 8310cf654..05386d6d6 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss @@ -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; diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts index 33933731c..fadc1ce65 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts @@ -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], diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html index ca817dbd2..674879dc3 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html @@ -21,6 +21,33 @@ Lien Marked Apply Withdrawal Fee for Transfers + + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (recurringDepositProductSettingsForm.value.preClosurePenalApplicable) { +
+ + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for ( + preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; + track preClosurePenalInterestOnType + ) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + +
+ } diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss index 3aefca054..a21434a6b 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss @@ -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; +} diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts index 74c8ca844..6a336eed0 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts @@ -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], diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html new file mode 100644 index 000000000..e2862f74f --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -0,0 +1,160 @@ + +

+ {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} Certificate + +

+ +
+ +
+
+
+ +
+ {{ accountType === 'FD' ? 'savings' : 'event_repeat' }} +
+ +
+
Nexora Co-operative Bank Ltd.
+
Mumbai, Maharashtra, India
+
+ {{ accountType === 'FD' ? 'FIXED DEPOSIT CERTIFICATE' : 'RECURRING DEPOSIT CERTIFICATE' }} +
+
Issued under the Common Seal of the Bank
+
+ +
+
+ Certificate No. + {{ certificateNo }} +
+
+ Account No. + {{ accountData.accountNo }} +
+
+ Date of Issue + {{ issueDate | dateFormat }} +
+
+ +
+

+ This is to certify that + {{ accountData.clientName || accountData.groupName }} + holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number + {{ accountData.accountNo }} + with Nexora Co-operative Bank Ltd., as per the particulars set out + below. +

+ +
+
ISSUED TO:
+
{{ accountData.clientName || accountData.groupName }}
+
+ Customer ID: {{ accountData.clientAccountNo }} + + · PAN: {{ panNumber }} +
+
+ {{ clientAddress }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product{{ accountData.depositProductName }}{{ accountType === 'FD' ? 'Deposit Date' : 'First Deposit Date' }}{{ depositDate | dateFormat }}
Deposit Amount + {{ accountData.depositAmount | currency: accountData.currency.code : 'symbol-narrow' }} + Tenure{{ tenureLabel }}
Installment Amount + {{ + accountData.mandatoryRecommendedDepositAmount | currency: accountData.currency.code : 'symbol-narrow' + }} + Installment Frequency + Every {{ accountData.recurringFrequency }} {{ accountData.recurringFrequencyType?.value }} +
Tenure{{ tenureLabel }}Interest Rate (p.a.){{ accountData.nominalAnnualInterestRate }}%
Interest Rate (p.a.){{ accountData.nominalAnnualInterestRate }}%Interest Compounding{{ accountData.interestCompoundingPeriodType?.value }}
Maturity Date{{ accountData.maturityDate | dateFormat }}Current Balance + {{ accountData.summary?.accountBalance | currency: accountData.currency.code : 'symbol-narrow' }} +
MATURITY AMOUNT + {{ accountData.maturityAmount | currency: accountData.currency.code : 'symbol-narrow' }} +
+ + +
+
+
+
+ + + + + diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss new file mode 100644 index 000000000..0db7334cb --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss @@ -0,0 +1,512 @@ +/** + * 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 */ + } +} + +/* 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; + } + } + + .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; + } + } +} diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts new file mode 100644 index 000000000..66f924cd5 --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts @@ -0,0 +1,132 @@ +/** + * 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); + 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(); + + 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.accountNo}`; + + 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: () => {} + }); + } + + 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(); + } +} From 5c76aa6b272d080dee9e14e91788517795a2a262 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 21 Aug 2026 13:00:25 +0530 Subject: [PATCH 4/5] feat(tasks,deposits): review product edits in a dialog showing only what changed The pending-product-edit comparison listed every field the edit form posted -- around fifty, nearly all unchanged -- as an inline panel under the table, which buried the handful a checker actually needs to look at. It is now a dialog listing only fields whose value differs. Values are resolved to what the maker saw rather than what the payload carries: enum and GL-account ids become their labels, dates in either API shape read the same, and empty values no longer register as changes. Without that, option fields compared an expanded {id, value} object against a bare id and reported every one of them as changed. Fixed Deposit accounts created by a reinvestment now show the account they came from, and the matured account shows the one it went into, each linking to the other. Co-Authored-By: Claude Opus 5 (1M context) --- .../general-tab/general-tab.component.html | 16 ++ .../general-tab/general-tab.component.ts | 21 ++ .../pending-savings-products.component.html | 40 +-- .../pending-savings-products.component.scss | 58 ---- .../pending-savings-products.component.ts | 61 ++-- .../product-edit-diff-dialog.component.html | 53 ++++ .../product-edit-diff-dialog.component.scss | 115 ++++++++ .../product-edit-diff-dialog.component.ts | 261 ++++++++++++++++++ 8 files changed, 504 insertions(+), 121 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html index 9ddcc0a99..e353a802e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html @@ -83,6 +83,22 @@ }} } + @if (fixedDepositsAccountData.reinvestedFromAccountNo) { +
+ {{ 'labels.inputs.Reinvested From' | translate }} + + {{ fixedDepositsAccountData.reinvestedFromAccountNo }} + +
+ } + @if (fixedDepositsAccountData.reinvestedIntoAccountNo) { +
+ {{ 'labels.inputs.Reinvested Into' | translate }} + + {{ fixedDepositsAccountData.reinvestedIntoAccountNo }} + +
+ } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts index a6175a18d..7da833264 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts @@ -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; + } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html index 5016189b8..ffccfe7d7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html @@ -49,8 +49,13 @@ Actions - - - @if (diffRows.length === 0) { -

Loading comparison…

- } @else { - - - - - - - - - - @for (row of diffRows; track row.label) { - - - - - - } - -
FieldOriginalProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
- } - - } } @if (!showData) { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss index 892bb22f2..cfc9860dd 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss @@ -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; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts index db9554f9d..cf4276918 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts @@ -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 { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html new file mode 100644 index 000000000..4e143f412 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html @@ -0,0 +1,53 @@ + +

+ difference + + Proposed changes — {{ productName }} + +

+ +
+ @if (rows.length === 0) { +
+ check_circle +

No changes to review

+

+ This edit was submitted without changing any product setting — every submitted value matches the current + product. +

+
+ } @else { +

+ {{ rows.length }} field{{ rows.length === 1 ? '' : 's' }} changed. Unchanged fields are hidden. +

+ + + + + + + + + + + @for (row of rows; track row.label) { + + + + + + } + +
FieldCurrentProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss new file mode 100644 index 000000000..3a82f2792 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts new file mode 100644 index 000000000..43e1e7dea --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -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); + 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 = { + 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 `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 = { + 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(); + + 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(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 + * `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); + } +} From f0e059c6211fb2f5590df966fd4ec7fcdba25228 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 23 Aug 2026 16:46:22 +0530 Subject: [PATCH 5/5] feat(deposits): FD Renewal History tab, redesigned Payment action, and renewal-aware Transactions/certificate WHAT THIS CHANGES ================== Frontend counterpart to the backend FD in-place renewal, senior-citizen rate, and Payment action work. 1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/. A vertical timeline, one card per term of the FD, oldest first. Backend data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL aliases), which the component normalises. Includes term-rate-chart-dialog, a popup showing one term's full rate-by-tenure chart, including the senior-citizen column, with an "Applied" badge on the rate that was actually used. Route added in fixed-deposits-routing.module.ts, tab link added in fixed-deposit-account-view.component.html (shown only when the account has renewal history). 2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/. Card-based picker for the four supported closure types (Transfer to Savings, Re-Invest Principal and Interest, Re-Invest Principal Only, Partial Re-Invest), with Withdraw Deposit filtered out client-side. Fetches its own template via a new fixedDepositsService method (getFixedDepositsAccountPaymentTemplate, command=payment) rather than reusing the Close template, since the Payment template is the only one the backend includes PARTIAL_REINVEST in. The Partial Re-Invest amount cap is date-dependent: the backend bounds it against the account balance AFTER posting interest to the chosen closed-on date, which lands on the maturity amount on or after maturity and on the current balance before it. The dialog mirrors this (reinvestCap / processingOnOrAfterMaturity) so the shown split actually adds up and the client-side validator agrees with the server. This was wrong in an earlier pass of this work (compared against a fixed figure regardless of date) and was caught and fixed via a real reconciliation check (77000 + 23000 did not equal the displayed total) before this commit. Added: a loading-failure state (previously the spinner ran forever on a failed template fetch), an inline submit-error message (previously a failed POST left the dialog open with no feedback), an empty-savings- accounts hint, and accessibility attributes on the option cards (role="radio", aria-checked, a visible check icon, focus-visible outline) since selection was previously colour-only. 3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the real transaction list, since an in-place renewal posts no actual ledger transaction and would otherwise look like the balance changed for no reason. 4. INTEREST RATE CHART TAB -- added the senior-citizen rate column. 5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD certificates. renewalCount is renewalHistory.length - 1 (the first row is the original term, not a renewal); an earlier version of this used the raw row count and overstated the renewal count by one on a customer-facing document, caught and fixed before this commit. 6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed FD's superseded terms out of the account list (each renewal used to show as two unrelated accounts, one Closed and one Active). Memoised on the source array's identity so it does not rebuild, and rebind the table, on every change-detection tick. Only rewrites FD rows; Savings, RD, Current, and Share account rows pass through this getter untouched, and the RD/Savings sections of the template were left bound directly to the original savingAccounts array. KNOWN CAVEATS ============= - This depends on the paired backend commit (same date, "feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action"). Deploying this frontend without that backend change means the Payment dialog and Renewal History tab will call endpoints that do not exist yet. - Renewal-history rows rely on the backend returning lowercase column keys (a Postgres-specific behaviour of unquoted SQL aliases via JdbcTemplate). Confirmed correct against this project's Postgres dev database; if this project ever runs on a different database engine, that assumption would need re-checking. - src/environments/.env.ts is intentionally excluded from this commit; it is a local build-version stamp file that should never be committed. Co-Authored-By: Claude Sonnet 5 --- .../general-tab/general-tab.component.html | 21 +- .../general-tab/general-tab.component.scss | 14 + .../general-tab/general-tab.component.ts | 52 ++- .../payment-dialog.component.html | 191 +++++++++ .../payment-dialog.component.scss | 296 ++++++++++++++ .../payment-dialog.component.ts | 288 ++++++++++++++ .../fixed-deposit-account-view.component.html | 15 +- .../fixed-deposit-account-view.component.ts | 29 ++ .../fixed-deposits-buttons.config.ts | 3 + .../interest-rate-chart-tab.component.html | 11 + .../interest-rate-chart-tab.component.ts | 1 + .../renewal-history-tab.component.html | 139 +++++++ .../renewal-history-tab.component.scss | 364 ++++++++++++++++++ .../renewal-history-tab.component.ts | 235 +++++++++++ .../term-rate-chart-dialog.component.html | 77 ++++ .../term-rate-chart-dialog.component.scss | 159 ++++++++ .../term-rate-chart-dialog.component.ts | 93 +++++ .../transactions-tab.component.html | 70 ++-- .../transactions-tab.component.scss | 28 ++ .../transactions-tab.component.ts | 68 +++- .../fixed-deposits-routing.module.ts | 10 + .../fixed-deposits/fixed-deposits.service.ts | 11 + .../deposit-certificate-dialog.component.html | 46 ++- .../deposit-certificate-dialog.component.scss | 44 +++ .../deposit-certificate-dialog.component.ts | 27 +- 25 files changed, 2256 insertions(+), 36 deletions(-) create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 2123fb7ae..242346b4e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -435,12 +435,17 @@ @if (!showClosedFixedAccounts) {
- +
@@ -524,12 +529,20 @@ @if (showClosedFixedAccounts) {
-
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + }
+
diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index 11848e360..f913a9808 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -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; diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index d957b00e2..333c43931 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -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(); + 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 { diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html new file mode 100644 index 000000000..659f79528 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -0,0 +1,191 @@ + + +
+
+ +
+

{{ 'labels.heading.Payment' | translate }}

+

Process this Fixed Deposit now -- works whether it's Active or Matured.

+
+
+ +
+ @if (loadingTemplate) { +
+ +
+ } @else if (loadFailed) { + + } @else { +
+ @if (reinvestCap !== null && reinvestCap !== undefined) { +
+ + {{ + remainderIsApproximate + ? 'Amount available on the selected date' + : ('labels.inputs.Maturity Amount' | translate) + }} + + {{ reinvestCap | formatNumber }} +
+ } + + + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (paymentForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + paymentForm.controls.onAccountClosureId.hasError('required') && + paymentForm.controls.onAccountClosureId.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (isPartialReinvest) { +
+ + {{ 'labels.inputs.Reinvest Amount' | translate }} + + @if (paymentForm.controls.reinvestAmount?.hasError('required')) { + + {{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (paymentForm.controls.reinvestAmount?.hasError('min')) { + {{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }} + } + @if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) { + {{ + 'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate + }} + } + + +
+ {{ 'labels.inputs.Remainder to Transfer' | translate }} + {{ + remainderAmount !== null ? (remainderAmount | formatNumber) : '—' + }} +
+
+ @if (remainderIsApproximate) { +

+ + 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. +

+ } + } + + @if (noDestinationAccounts) { + + } + + @if (paymentForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (paymentForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (paymentForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + + {{ 'labels.inputs.Note' | translate }} + + + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss new file mode 100644 index 000000000..48967437c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -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; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts new file mode 100644 index 000000000..cd433f83c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -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 = { + [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 = { + [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); + 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. + *

+ * 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.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html index 793cce9cb..0ac452a8e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html @@ -37,7 +37,7 @@ {{ 'labels.inputs.Account Number' | translate }} @@ -230,6 +230,19 @@ {{ 'labels.inputs.Transactions' | translate }} } + @if (fixedDepositsAccountData.renewalHistory?.length) { + + + {{ 'labels.inputs.Renewal History' | translate }} + + } { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 271987c9e..3146a730c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration { }, { name: 'Add Charge' + }, + { + name: 'Payment' } ]; break; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html index 5e9de8301..bf81fc96f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html @@ -36,6 +36,17 @@

+ + + + + diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts index 7934d41b5..f400aa9f4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts @@ -71,6 +71,7 @@ export class InterestRateChartTabComponent { 'period', 'amountRange', 'interest', + 'seniorCitizen', 'description', 'actions' ]; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html new file mode 100644 index 000000000..3648e2ee1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html @@ -0,0 +1,139 @@ + + +
+ @if (renewalHistory.length) { + +
+
+ FD Number + {{ fdNumber }} +
+
+ Terms So Far + {{ renewalHistory.length }} +
+
+ Original Principal + {{ originalPrincipal | currency: currency.code : 'symbol-narrow' }} +
+
+ Current Value + {{ currentValue | currency: currency.code : 'symbol-narrow' }} +
+
+ Lifetime Interest + {{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }} +
+
+ + +
+ } @else { +
+ +

{{ 'labels.messages.No Renewal History Available' | translate }}

+
+ } +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss new file mode 100644 index 000000000..bb6e474cd --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss @@ -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; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts new file mode 100644 index 000000000..34bd1bb2b --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts @@ -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 = { + 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 + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html new file mode 100644 index 000000000..7eadb1179 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html @@ -0,0 +1,77 @@ + + +
+

+ + + {{ termLabel }} — Rate Chart + {{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }} + +

+ +
+ @if (slabs.length) { +
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + } {{ chartSlab.annualInterestRate | formatNumber }} % Senior Citizen + {{ + chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined + ? (chartSlab.seniorCitizenRate | formatNumber) + ' %' + : '-' + }} + {{ 'labels.inputs.Description' | translate }} {{ chartSlab.description }}
+ + + + + + + + + + @for (slab of slabs; track $index) { + + + + + + + } + +
DescriptionTenureRate + + Senior Citizen +
{{ slab.description }}{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }} + {{ slab.annualInterestRate }}% + @if (isAppliedSlab(slab)) { + Applied + } + + {{ + slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined + ? slab.seniorCitizenRate + '%' + : '—' + }} + @if (isAppliedSeniorSlab(slab)) { + Applied + } +
+ @if (hasLiveSeniorRate) { +

+ + Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for + reference. +

+ } + } @else { +
+ +

No rate chart was archived for this term.

+
+ } +
+ +
+ +
+ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss new file mode 100644 index 000000000..a0f4ec851 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss @@ -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; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts new file mode 100644 index 000000000..3ce7957f9 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts @@ -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); + 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) + ); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index ae4dafdf5..6e447a59c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -36,7 +36,11 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.id }} + @if (transaction.isRenewalEvent) { + + } @else { + {{ transaction.id }} + }
@@ -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' + }} @@ -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' + }} @@ -96,36 +112,40 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.runningBalance | formatNumber }} + {{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }} {{ 'labels.inputs.Actions' | translate }} - - - - @if (!isFirstTransaction(idx)) { - - } - - + @if (!isFirstTransaction(idx)) { + + } + + + } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss index 44062e84c..57358a351 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss @@ -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; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts index 5caa3e47e..07691eaa6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts @@ -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) { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts index 68861fff9..eb2f6e5c5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts @@ -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, diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index 6d20ed276..dd82ede92 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -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} + */ + getFixedDepositsAccountPaymentTemplate(accountId: string): Observable { + 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} diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html index e2862f74f..f5d32347b 100644 --- a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -36,7 +36,7 @@
Account No. - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }}
Date of Issue @@ -49,9 +49,13 @@ This is to certify that {{ accountData.clientName || accountData.groupName }} holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }} with Nexora Co-operative Bank Ltd., as per the particulars set out below. + + This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the + full renewal history is set out below. +

@@ -129,6 +133,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RENEWAL HISTORY
TermFromToRatePrincipalInterestMaturity Amount
{{ term.renewalcycleno === 0 ? 'Original' : 'Renewal ' + term.renewalcycleno }}{{ term.activatedondate | dateFormat }}{{ term.maturitydate | dateFormat }}{{ term.interestrate }}%{{ term.depositamount | currency: accountData.currency.code : 'symbol-narrow' }} + {{ + term.interestearned === null || term.interestearned === undefined + ? '—' + : (term.interestearned | currency: accountData.currency.code : 'symbol-narrow') + }} + {{ maturityFor(term) | currency: accountData.currency.code : 'symbol-narrow' }}
+