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 }); - } - } } /** 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/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/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) {
+ } +
+ @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 55da78695..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 }} @@ -79,6 +79,16 @@

+ @if (!fixedDepositsAccountData.status.rejected && !fixedDepositsAccountData.status.submittedAndPendingApproval) { + + } @@ -220,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. */ @@ -358,6 +388,20 @@ export class FixedDepositAccountViewComponent implements OnInit { }); } + /** + * Opens a printable deposit certificate for this account. + */ + printCertificate() { + this.dialog.open(DepositCertificateDialogComponent, { + width: '900px', + data: { + accountType: 'FD', + accountData: this.fixedDepositsAccountData, + clientId: this.fixedDepositsAccountData.clientId + } + }); + } + /** * Disables withhold tax for fixed deposits account * Fixed deposits endpoint is not supported so using Savings endpoint. 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/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) { + + } + @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/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' }} +
+
+ + +
+ @for ( + term of renewalHistory; + track term.historyid ?? term.accountid + '-' + term.renewalcycleno; + let last = $last + ) { +
+
+ + @if (term.renewalstatus === 'ACTIVE') { + + } + + @if (!last) { + + } +
+ +
+
+
+

+ {{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }} +

+ + + {{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }} + + @if (renewalTypeLabel(term)) { + + + {{ renewalTypeLabel(term) }} + + } +
+ +
+ +
+ + {{ term.activatedondate | dateFormat }} + + {{ term.maturitydate | dateFormat }} + @if (tenureLabel(term)) { + ({{ tenureLabel(term) }}) + } +
+ +
+
+ Principal + {{ term.depositamount | currency: currency.code : 'symbol-narrow' }} +
+
+ Rate + {{ term.interestrate }}% +
+
+ {{ + term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned' + }} + + {{ + term.interestearned === null || term.interestearned === undefined + ? '—' + : (term.interestearned | currency: currency.code : 'symbol-narrow') + }} + +
+
+ {{ + term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At' + }} + + {{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }} + +
+
+ +
+ + + Ledger account: + @if (term.accountid === fixedDepositsAccountData.id) { + {{ term.accountno }} + } @else { + {{ term.accountno }} + } + +
+
+
+ } +
+ } @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-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/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/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-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/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/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 { 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..f5d32347b --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -0,0 +1,202 @@ + +

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

+ +
+
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' }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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' }}
+ + +
+
+
+
+ + + + + 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..744d3b9df --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss @@ -0,0 +1,556 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.dialog-header { + display: flex; + align-items: center; + border-bottom: 1px solid #e0e0e0; + padding-bottom: 12px; + margin-bottom: 16px; + font-weight: 500; + color: #1a252c; +} + +.dialog-content-container { + max-height: 80vh; + overflow-y: auto; + padding: 10px; +} + +/* Certificate Outer Container */ +.certificate-container { + background-color: #faf8f5; + padding: 20px; + font-family: Georgia, 'Times New Roman', Times, serif; + color: #2c2520; + box-shadow: 0 4px 20px rgb(0 0 0 / 8%); + border-radius: 8px; + max-width: 800px; + margin: 0 auto; +} + +/* Classic Ornate Border System */ +.certificate-border-outer { + border: 5px double #c5a059; /* Gold double border */ + padding: 8px; + background-color: #fdfcf7; /* Textured light cream */ + border-radius: 4px; + position: relative; +} + +.certificate-border-inner { + border: 1px solid #dcd1bd; + padding: 30px; + border-radius: 2px; + position: relative; + overflow: hidden; +} + +/* Watermark */ +.certificate-watermark { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0.03; + pointer-events: none; + z-index: 1; + + .watermark-icon { + font-size: 280px; + width: 280px; + height: 280px; + color: #c5a059; + } +} + +.certificate-header, +.cert-meta-row, +.cert-body-section, +.certificate-details-table, +.cert-footer { + position: relative; + z-index: 2; /* Ensure content is above watermark */ +} + +/* Header styling */ +.certificate-header { + text-align: center; + margin-bottom: 30px; + border-bottom: 2px solid #f2edd5; + padding-bottom: 20px; + + .bank-name { + font-size: 26px; + font-weight: bold; + color: #0b3c5d; /* Dark Premium Blue */ + text-transform: uppercase; + letter-spacing: 1.5px; + margin-bottom: 4px; + } + + .bank-sub { + font-size: 13px; + color: #7b6f63; + font-style: italic; + letter-spacing: 1px; + margin-bottom: 20px; + } + + .cert-title { + font-size: 28px; + font-weight: 400; + color: #c5a059; /* Classic Gold */ + letter-spacing: 4px; + margin-bottom: 5px; + font-family: Georgia, serif; + } + + .cert-subtitle { + font-size: 11px; + color: #7b6f63; + text-transform: uppercase; + letter-spacing: 2px; + } +} + +/* Meta Data Grid (Top of cert) */ +.cert-meta-row { + display: flex; + justify-content: space-between; + margin-bottom: 30px; + border-bottom: 1px solid #f2edd5; + padding-bottom: 15px; + + .meta-item { + display: flex; + flex-direction: column; + + .meta-label { + font-size: 11px; + text-transform: uppercase; + color: #928374; + letter-spacing: 1px; + margin-bottom: 3px; + } + + .meta-value { + font-size: 15px; + font-weight: bold; + color: #322f2b; + } + } +} + +/* Main Body text */ +.cert-body-section { + margin-bottom: 35px; + + .cert-main-text { + font-size: 15px; + line-height: 1.8; + text-align: center; + color: #4a453f; + margin-bottom: 25px; + + .highlight-text { + color: #1a202c; + font-weight: 600; + border-bottom: 1px dotted #c5a059; + padding-bottom: 1px; + } + } +} + +/* Issued To Details Block */ +.issued-to-details { + border: 1px solid #f0e6d2; + background-color: #fbfaf5; + padding: 15px 20px; + border-radius: 4px; + margin: 20px auto; + max-width: 90%; + text-align: left; + + .issued-to-title { + font-size: 10px; + font-weight: bold; + color: #c5a059; + letter-spacing: 1.5px; + margin-bottom: 6px; + } + + .client-name-details { + font-size: 18px; + font-weight: bold; + color: #0b3c5d; + margin-bottom: 4px; + } + + .client-ids { + font-size: 12px; + color: #666; + margin-bottom: 6px; + } + + .client-address { + font-size: 12px; + color: #7b6f63; + line-height: 1.4; + } +} + +/* Certificate Data Table */ +.certificate-details-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 40px; + + td { + padding: 10px 12px; + border: 1px solid #ede8db; + font-size: 13px; + } + + .lbl-col { + background-color: #f7f5ee; + font-weight: bold; + color: #7b6f63; + width: 25%; + } + + .val-col { + color: #322f2b; + width: 25%; + } + + .highlight-row { + background-color: #f5f2e9; + border-top: 2px solid #c5a059; + } + + .lbl-col-highlight { + font-weight: bold; + color: #0b3c5d; + font-size: 14px; + letter-spacing: 1px; + text-align: right; + padding-right: 20px; + } + + .val-col-highlight { + font-size: 16px; + font-weight: bold; + color: #b8860b; /* Dark Goldenrod */ + } +} + +/* Renewal History Table -- lists each earlier term this FD was renewed from */ +.renewal-history-table { + th { + padding: 8px 10px; + border: 1px solid #ede8db; + font-size: 11px; + text-align: left; + background-color: #f7f5ee; + color: #7b6f63; + } + + .renewal-history-title { + text-align: center; + background-color: #f5f2e9; + color: #0b3c5d; + font-weight: bold; + letter-spacing: 1.5px; + font-size: 12px; + } + + .current-term-row { + background-color: #fbfaf5; + font-weight: bold; + } +} + +/* Certificate Footer Seal / Signatures */ +.cert-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + + .seal-container { + width: 25%; + + .seal-ring { + width: 90px; + height: 90px; + border: 2px dashed #b8860b; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + transform: rotate(-15deg); + + .seal-text { + font-size: 10px; + font-weight: bold; + color: #b8860b; + text-align: center; + line-height: 1.2; + letter-spacing: 1px; + } + } + } + + .closing-text { + width: 45%; + font-size: 12px; + color: #7b6f63; + font-style: italic; + line-height: 1.5; + text-align: center; + } + + .signatures-container { + width: 30%; + text-align: center; + + .signature-block { + display: inline-block; + width: 80%; + + .signature-line { + border-bottom: 1px solid #7b6f63; + margin-bottom: 8px; + height: 40px; /* Space for signature */ + } + + .signatory-title { + font-size: 12px; + color: #7b6f63; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + } +} + +/* Print CSS */ +@media print { + /* Everything below `::ng-deep` reaches outside this component's own template -- html/body and + the whole Angular Material CDK overlay chain never carry this component's scoping attribute + under the default emulated encapsulation, so a plain (scoped) selector would silently never + match any of them. `::ng-deep` is deprecated but still the correct tool for a component that + genuinely needs to override its host application's shell during print. */ + + /* The app shell pins html/body to a fixed viewport height with overflow hidden (its inner + panels handle their own scrolling on screen) -- left in place, that caps every printed page + at one viewport's worth of content and silently cuts off the rest instead of paginating. */ + ::ng-deep html, + ::ng-deep body { + height: auto !important; + overflow: visible !important; + } + + /* Hide non-overlay contents during print */ + ::ng-deep body > :not(.cdk-overlay-container) { + display: none !important; + } + + /* The dialog's own scroll region clips anything past 80vh -- printing only captures what a + scrollable/max-height box shows on screen, so both constraints must be lifted or the + certificate gets cut off after its first visible slice. */ + .dialog-content-container { + max-height: none !important; + overflow: visible !important; + } + + /* CDK's overlay wrapper/backdrop/pane are all `position: fixed` (needed to center the dialog + on screen) and the wrapper flex-centers its content -- during print, a fixed-position + ancestor is clipped to one page's height, so anything taller than that gets cut off and only + a thin cross-section (here, just the certificate's own border) survives. Every ancestor in + that chain has to become a normal, statically-flowing block for the full certificate to print. */ + ::ng-deep .cdk-overlay-backdrop { + display: none !important; + } + + ::ng-deep .cdk-overlay-container, + ::ng-deep .cdk-global-overlay-wrapper, + ::ng-deep .cdk-overlay-pane, + ::ng-deep .mat-mdc-dialog-container, + ::ng-deep .mdc-dialog__container, + ::ng-deep .mdc-dialog__surface { + position: static !important; + display: block !important; + max-height: none !important; + max-width: none !important; + height: auto !important; + width: auto !important; + overflow: visible !important; + transform: none !important; + } + + /* Make certificate fill page */ + #deposit-certificate-print { + position: static !important; + width: 100% !important; + height: auto !important; + padding: 0 !important; + margin: 0 !important; + background-color: #fff !important; + box-shadow: none !important; + } + + .certificate-border-outer { + border-width: 8px !important; + background-color: #fff !important; + } + + .no-print { + display: none !important; + } + + ::ng-deep .mat-mdc-dialog-container { + padding: 0 !important; + } + + /* Compact every spacing/font value so the whole certificate fits a single printed page -- + the on-screen sizing is generous by design, but that same height doesn't fit one page. */ + .certificate-container { + padding: 6px !important; + } + + .certificate-border-inner { + padding: 14px !important; + } + + .certificate-header { + margin-bottom: 10px !important; + padding-bottom: 10px !important; + + .bank-name { + font-size: 19px !important; + margin-bottom: 2px !important; + } + + .bank-sub { + font-size: 10px !important; + margin-bottom: 8px !important; + } + + .cert-title { + font-size: 19px !important; + letter-spacing: 2px !important; + margin-bottom: 2px !important; + } + + .cert-subtitle { + font-size: 9px !important; + } + } + + .cert-meta-row { + margin-bottom: 10px !important; + padding-bottom: 8px !important; + + .meta-label { + font-size: 9px !important; + margin-bottom: 1px !important; + } + + .meta-value { + font-size: 12px !important; + } + } + + .cert-body-section { + margin-bottom: 10px !important; + + .cert-main-text { + font-size: 11px !important; + line-height: 1.4 !important; + margin-bottom: 10px !important; + } + } + + .issued-to-details { + padding: 8px 14px !important; + margin: 8px auto !important; + + .client-name-details { + font-size: 14px !important; + } + + .client-ids, + .client-address { + font-size: 10px !important; + } + } + + .certificate-details-table { + margin-bottom: 12px !important; + + td { + padding: 5px 10px !important; + font-size: 10px !important; + } + + .lbl-col-highlight { + font-size: 12px !important; + } + + .val-col-highlight { + font-size: 13px !important; + } + } + + .renewal-history-table { + margin-bottom: 12px !important; + + th { + padding: 4px 8px !important; + font-size: 8px !important; + } + + td { + padding: 4px 8px !important; + font-size: 9px !important; + } + + .renewal-history-title { + font-size: 10px !important; + } + } + + .cert-footer { + margin-top: 8px !important; + + .seal-ring { + width: 60px !important; + height: 60px !important; + } + + .seal-text { + font-size: 8px !important; + } + + .closing-text { + font-size: 10px !important; + } + + .signature-line { + height: 22px !important; + } + + .signatory-title { + font-size: 10px !important; + } + } +} 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..27ad5f9f3 --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts @@ -0,0 +1,157 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { ClientsService } from 'app/clients/clients.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DateFormatPipe } from '../../pipes/date-format.pipe'; +import { CurrencyPipe } from '@angular/common'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; + +/** + * Printable deposit certificate for a Fixed Deposit or Recurring Deposit account -- handed to the + * customer as physical proof of their deposit. Shared between FD and RD since both account shapes + * carry the same core fields (depositAmount, nominalAnnualInterestRate, depositPeriod, maturityDate, + * maturityAmount); RD additionally shows its installment/frequency details. + */ +@Component({ + selector: 'mifosx-deposit-certificate-dialog', + templateUrl: './deposit-certificate-dialog.component.html', + styleUrls: ['./deposit-certificate-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + DateFormatPipe, + CurrencyPipe, + MatButton, + MatIcon + ] +}) +export class DepositCertificateDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + clientsService = inject(ClientsService); + + /** 'FD' or 'RD' -- controls the title and which extra rows show. */ + accountType: 'FD' | 'RD'; + accountData: any; + + clientData: any; + clientAddress = ''; + panNumber = 'Not Available'; + + certificateNo = ''; + issueDate: Date = new Date(); + + /** + * Every term of this FD, oldest first, as resolved by the backend -- a renewal opens each term as + * its own account, so the certificate lists them all to show the deposit's full history. Empty for + * an FD that has never been renewed. Keys are lower-cased, arriving from SQL column labels. + */ + renewalHistory: any[] = []; + + /** + * How many times the deposit has actually rolled over. {@link renewalHistory} lists every TERM, and the + * first of those is the original deposit rather than a renewal, so the count is one less than the number + * of rows. Stated on a customer-facing certificate, so an off-by-one here misreports the deposit. + */ + get renewalCount(): number { + return Math.max(this.renewalHistory.length - 1, 0); + } + + ngOnInit() { + this.accountType = this.data.accountType; + this.accountData = this.data.accountData; + const clientId = this.data.clientId != null ? String(this.data.clientId) : null; + + this.certificateNo = `${this.accountType}-CERT-${this.accountData.fdAccountNumber || this.accountData.accountNo}`; + this.renewalHistory = this.accountData?.renewalHistory ?? []; + + if (!clientId) { + return; + } + + this.clientsService.getClientData(clientId).subscribe({ + next: (client: any) => { + this.clientData = client; + }, + error: () => {} + }); + + this.clientsService.getClientAddressData(clientId).subscribe({ + next: (addresses: any[]) => { + if (addresses && addresses.length > 0) { + const permanent = addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0]; + this.clientAddress = this.formatAddress(permanent); + } + }, + error: () => {} + }); + + this.clientsService.getClientIdentifiers(clientId).subscribe({ + next: (identifiers: any[]) => { + const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || []; + const panId = idList.find((id: any) => /pan/i.test(id?.documentType?.name || '')); + if (panId) { + this.panNumber = panId.documentKey; + } + }, + error: () => {} + }); + } + + /** + * A term's settled maturity amount, or its projection while it is still running. Resolved here rather + * than inline so the choice isn't left to operator precedence between `??` and the currency pipe. + */ + maturityFor(term: any): number | null { + return term?.maturedamount ?? term?.projectedmaturityamount ?? null; + } + + formatAddress(addr: any): string { + if (!addr) return ''; + return [ + addr.addressLine1, + addr.addressLine2, + addr.townVillage, + addr.city, + addr.stateName, + addr.countryName, + addr.postalCode + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + } + + get depositDate(): any { + return this.accountData?.timeline?.activatedOnDate ?? this.accountData?.timeline?.submittedOnDate ?? null; + } + + get tenureLabel(): string { + const period = this.accountData?.depositPeriod; + const freq = this.accountData?.depositPeriodFrequency?.value; + if (period == null) return 'N/A'; + return freq ? `${period} ${freq}` : String(period); + } + + printCertificate() { + window.print(); + } +} 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 (selectedEdit) { -
-
-

Comparing "{{ selectedEdit.savingsProductName }}" — Original vs Proposed

- -
- @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); + } +}