diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 2123fb7ae..242346b4e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -435,12 +435,17 @@ @if (!showClosedFixedAccounts) {
- +
@@ -524,12 +529,20 @@ @if (showClosedFixedAccounts) {
-
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + }
+
diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index 11848e360..f913a9808 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -209,6 +209,20 @@ $red: #d93025; gap: 0; } +// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number +// rather than this term's own account number. +.fd-renewal-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 7px; + border-radius: 10px; + background-color: $gray-200; + color: $gray-700; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + // ── Table Wrapper ── .table-wrapper { border-radius: 0 0 8px 8px; diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index d957b00e2..333c43931 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy { } get fixedDepositCount(): number { - return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length; + return this.fixedDepositAccountsForDisplay.filter( + (a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit' + ).length; + } + + /** + * Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away. + * + * An FD renewal closes one account and opens the next term as its own account row, so a renewed FD + * would otherwise appear twice on this page -- the matured term under Closed and the new term under + * Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later + * cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its + * current term. A chain that has genuinely ended keeps its final term, so it still lists as closed. + * + * Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched. + */ + get fixedDepositAccountsForDisplay(): any[] { + // Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so + // returning a freshly built array on every change-detection pass would make the table re-bind and + // re-render its rows on every tick -- mouse moves included. + if (this.fdDisplayCacheSource !== this.savingAccounts) { + this.fdDisplayCacheSource = this.savingAccounts; + this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts); + } + return this.fdDisplayCache; + } + + private fdDisplayCacheSource: any[] = null; + private fdDisplayCache: any[] = []; + + private foldSupersededFdCycles(accounts: any[]): any[] { + const latestCycleByFd = new Map(); + for (const account of accounts) { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + continue; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) { + latestCycleByFd.set(fdNumber, cycle); + } + } + + return accounts.filter((account: any) => { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + return true; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + return cycle === latestCycleByFd.get(fdNumber); + }); } get recurringDepositCount(): number { diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html new file mode 100644 index 000000000..659f79528 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -0,0 +1,191 @@ + + +
+
+ +
+

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

+

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

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

+ + The selected date is before maturity. Interest up to that date is posted first, so the amount actually + transferred will be at least the figure shown. +

+ } + } + + @if (noDestinationAccounts) { + + } + + @if (paymentForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (paymentForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (paymentForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + + {{ 'labels.inputs.Note' | translate }} + + + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss new file mode 100644 index 000000000..48967437c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -0,0 +1,296 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$blue: #1e40af; +$blue-soft: #eff6ff; +$green: #166534; +$green-soft: #f0fdf4; + +.pmt { + display: flex; + flex-direction: column; + min-height: 320px; + max-height: 85vh; + overflow: hidden; + margin: -24px; +} + +.pmt__header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 22px 28px 18px; + background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%); + color: #fff; + flex: 0 0 auto; +} + +.pmt__header-ico { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 10px; + background: rgb(255 255 255 / 15%); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.pmt__title { + margin: 0; + font-size: 19px; + font-weight: 700; +} + +.pmt__subtitle { + margin: 4px 0 0; + font-size: 12.5px; + color: rgb(255 255 255 / 82%); + line-height: 1.4; +} + +.pmt__content { + padding: 22px 28px 8px !important; + overflow-y: auto; + flex: 1 1 auto; +} + +.pmt__loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 260px; +} + +.pmt__stat { + display: flex; + flex-direction: column; + gap: 2px; + background: $blue-soft; + border: 1px solid #dbeafe; + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 18px; +} + +.pmt__stat-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $blue; +} + +.pmt__stat-value { + font-size: 22px; + font-weight: 800; + color: $navy; + font-variant-numeric: tabular-nums; +} + +.pmt__date { + width: 100%; +} + +.pmt__section-label { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $slate; + margin: 4px 0 10px; +} + +.pmt__options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} + +.pmt__option { + display: flex; + align-items: flex-start; + gap: 10px; + text-align: left; + background: #fff; + border: 1.5px solid $border; + border-radius: 10px; + padding: 12px 14px; + cursor: pointer; + font: inherit; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; +} + +.pmt__option:hover { + border-color: #93c5fd; + background: $blue-soft; +} + +.pmt__option--active { + border-color: $blue; + background: $blue-soft; + box-shadow: 0 0 0 1px $blue; +} + +.pmt__option-ico { + width: 30px; + height: 30px; + min-width: 30px; + border-radius: 8px; + background: $soft; + color: $blue; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.pmt__option--active .pmt__option-ico { + background: $blue; + color: #fff; +} + +.pmt__option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pmt__option-title { + font-size: 13px; + font-weight: 700; + color: $navy; +} + +.pmt__option-desc { + font-size: 11px; + color: $muted; + line-height: 1.35; +} + +.pmt__option:focus-visible { + outline: 2px solid $blue; + outline-offset: 2px; +} + +.pmt__option-check { + margin-left: auto; + color: $blue; + font-size: 13px; +} + +.pmt__option-error { + color: #b91c1c; + font-size: 12px; + margin: 4px 0 14px; +} + +.pmt__error { + display: flex; + align-items: flex-start; + gap: 4px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12.5px; + line-height: 1.45; + padding: 10px 14px; + margin: 4px 0 8px; +} + +.pmt__hint { + display: flex; + align-items: flex-start; + gap: 4px; + background: $soft; + border: 1px solid $border; + border-radius: 8px; + color: $slate; + font-size: 12px; + line-height: 1.45; + padding: 9px 13px; + margin: 0 0 10px; +} + +.pmt__hint--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.pmt__split { + display: flex; + align-items: center; + gap: 12px; + background: $soft; + border: 1px dashed #cbd5e1; + border-radius: 10px; + padding: 14px 16px 0; + margin: 4px 0 8px; + flex-wrap: wrap; +} + +.pmt__split-input { + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-arrow { + color: $muted; + font-size: 14px; + margin-bottom: 20px; +} + +.pmt__split-remainder { + display: flex; + flex-direction: column; + gap: 2px; + background: $green-soft; + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 8px 14px; + margin-bottom: 14px; + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-remainder-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $green; +} + +.pmt__split-remainder-value { + font-size: 16px; + font-weight: 800; + color: $green; + font-variant-numeric: tabular-nums; +} + +.pmt__content mat-form-field { + width: 100%; +} + +.pmt__actions { + padding: 14px 28px 20px !important; + border-top: 1px solid $border; + flex: 0 0 auto; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts new file mode 100644 index 000000000..cd433f83c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -0,0 +1,288 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; + +/** Custom Services */ +import { FixedDepositsService } from '../../../fixed-deposits.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and + * Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice, + * Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the + * entered amount renews on this same account, the rest transfers to a savings account picked here. + */ +@Component({ + selector: 'mifosx-payment-dialog', + templateUrl: './payment-dialog.component.html', + styleUrls: ['./payment-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + MatProgressSpinnerModule, + FaIconComponent, + FormatNumberPipe + ] +}) +export class PaymentDialogComponent implements OnInit { + /** The `onAccountClosureId` codes each closure type is registered under on the backend. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + private static readonly REINVEST_FULL_ID = 300; + private static readonly REINVEST_PRINCIPAL_ID = 400; + private static readonly PARTIAL_REINVEST_ID = 500; + + private static readonly ACTION_ICONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: + 'Renew only the principal; interest transferred to a savings account', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest' + }; + + dialogRef = inject>(MatDialogRef); + data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + paymentForm: UntypedFormGroup; + /** Loaded once, on open, from the same closure template the existing Close action already uses. */ + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + maturityAmount: number = null; + loadingTemplate = true; + loadFailed = false; + submitting = false; + submitError: string = null; + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.paymentForm = this.formBuilder.group({ + closedOnDate: [ + '', + Validators.required + ], + onAccountClosureId: [ + '', + Validators.required + ], + note: [''] + }); + this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + // The cap depends on whether the chosen date is on or after maturity, so a date change can make an + // already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date. + this.paymentForm + .get('closedOnDate') + .valueChanges.subscribe(() => + this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false }) + ); + + this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({ + next: (response: any) => { + // Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a + // staff-triggered Payment that may run well before or after the actual counter transaction -- + // Transfer to Savings or a Reinvest option covers every real case instead. + this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter( + (option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID + ); + this.savingsAccountsData = response.savingsAccounts ?? []; + this.maturityAmount = response.maturityAmount; + this.loadingTemplate = false; + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out + // and no indication of why the form never appeared. + error: () => { + this.loadingTemplate = false; + this.loadFailed = true; + } + }); + } + + /** + * The amount being split, and the bound the entered partial amount is checked against. + *

+ * The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure + * matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the + * maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current + * balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept + * an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger. + */ + get reinvestCap(): number | null { + if (this.processingOnOrAfterMaturity) { + return this.maturityAmount ?? this.data.accountBalance ?? null; + } + return this.data.accountBalance ?? this.maturityAmount ?? null; + } + + /** Whether the chosen closure date is on or after the deposit's maturity date. */ + private get processingOnOrAfterMaturity(): boolean { + const chosen = this.paymentForm?.get('closedOnDate')?.value; + if (!chosen || !this.data.maturityDate) { + return false; + } + return this.toTime(chosen) >= this.toTime(this.data.maturityDate); + } + + /** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */ + private toTime(value: any): number { + if (value instanceof Date) { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + + /** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */ + get remainderIsApproximate(): boolean { + return !this.processingOnOrAfterMaturity; + } + + iconFor(id: number): string { + return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.paymentForm.get('onAccountClosureId').setValue(id); + this.paymentForm.get('onAccountClosureId').markAsTouched(); + } + + /** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + const needsDestinationAccount = + id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID || + id === PaymentDialogComponent.PARTIAL_REINVEST_ID || + id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID; + if (needsDestinationAccount) { + if (!this.paymentForm.contains('toSavingsAccountId')) { + this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + this.paymentForm.addControl('transferDescription', new UntypedFormControl('')); + } + } else { + this.paymentForm.removeControl('toSavingsAccountId'); + this.paymentForm.removeControl('transferDescription'); + } + + if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) { + if (!this.paymentForm.contains('reinvestAmount')) { + this.paymentForm.addControl( + 'reinvestAmount', + new UntypedFormControl('', [ + Validators.required, + Validators.min(0.01), + this.belowMaturityAmountValidator() + ]) + ); + } + } else { + this.paymentForm.removeControl('reinvestAmount'); + } + } + + /** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */ + private belowMaturityAmountValidator(): ValidatorFn { + return (control) => { + const cap = Number(this.reinvestCap); + if (!cap || control.value === null || control.value === '') { + return null; + } + return Number(control.value) >= cap ? { belowMaturityAmount: true } : null; + }; + } + + get isPartialReinvest(): boolean { + return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID; + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0 + ); + } + + /** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */ + get remainderAmount(): number | null { + const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value; + if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) { + return null; + } + return Number(this.reinvestCap) - Number(reinvestAmount); + } + + submit(): void { + const formValue = { ...this.paymentForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data) + .subscribe({ + next: () => { + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This payment could not be processed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html index 793cce9cb..0ac452a8e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html @@ -37,7 +37,7 @@ {{ 'labels.inputs.Account Number' | translate }} @@ -230,6 +230,19 @@ {{ 'labels.inputs.Transactions' | translate }} } + @if (fixedDepositsAccountData.renewalHistory?.length) { + + + {{ 'labels.inputs.Renewal History' | translate }} + + } { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 271987c9e..3146a730c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration { }, { name: 'Add Charge' + }, + { + name: 'Payment' } ]; break; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html index 5e9de8301..bf81fc96f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html @@ -36,6 +36,17 @@

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

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

+
+ } +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss new file mode 100644 index 000000000..bb6e474cd --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss @@ -0,0 +1,364 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; +$accent: #2563eb; +$accent-soft: #eff6ff; + +.rh-container { + max-width: 860px; + margin: 0 auto; + padding: 4px 0 24px; +} + +/* ─── Summary strip ─────────────────────────────────────────────────── */ +.rh-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + background: $border; + border: 1px solid $border; + border-radius: 14px; + overflow: hidden; + margin-bottom: 28px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + + &__item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + background: #fff; + } + + &__label { + font-size: 10.5px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: 15px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + } + + &__item--highlight { + background: $accent-soft; + + .rh-summary__value { + color: $accent; + } + } + + &__item--positive { + background: $positive-soft; + + .rh-summary__value { + color: $positive; + } + } +} + +@media (width <= 720px) { + .rh-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* ─── Timeline rail ──────────────────────────────────────────────────── */ +.rh-timeline { + display: flex; + flex-direction: column; +} + +.rh-node { + display: flex; + gap: 18px; +} + +.rh-node__rail { + flex: none; + width: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.rh-node__dot { + position: relative; + width: 16px; + height: 16px; + margin-top: 22px; + border-radius: 50%; + background: #cbd5e1; + border: 3px solid #fff; + box-shadow: 0 0 0 2px #cbd5e1; + flex: none; + + &--active { + background: $positive; + box-shadow: 0 0 0 2px $positive; + } +} + +.rh-node__pulse { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid $positive; + opacity: 0.6; + animation: rh-pulse 1.8s ease-out infinite; +} + +@keyframes rh-pulse { + 0% { + transform: scale(0.6); + opacity: 0.7; + } + + 100% { + transform: scale(1.6); + opacity: 0; + } +} + +.rh-node__line { + flex: 1; + width: 2px; + min-height: 24px; + background: $border; + margin: 4px 0; +} + +/* ─── Term card ──────────────────────────────────────────────────────── */ +.rh-card { + flex: 1; + min-width: 0; + background: #fff; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + padding: 16px 18px; + margin-bottom: 20px; + transition: box-shadow 0.15s ease; + + &--active { + border-color: rgb(4 120 87 / 20%); + box-shadow: 0 2px 10px rgb(4 120 87 / 8%); + } + + &:hover { + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + } + + &__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + &__title-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__period { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12.5px; + color: $slate; + font-weight: 500; + + &-ico { + color: $muted; + font-size: 11px; + } + + &-arrow { + color: $muted; + font-size: 10px; + } + } + + &__tenure { + color: $muted; + font-weight: 400; + } + + &__foot { + display: flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed $border; + font-size: 11.5px; + color: $muted; + + &-ico { + font-size: 10px; + } + + a { + color: $accent; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +/* ─── Status badge / auto-renewed tag ───────────────────────────────── */ +.rh-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $soft; + color: $muted; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + + &--active { + background: $positive-soft; + color: $positive; + } +} + +.rh-tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $accent-soft; + color: $accent; + font-size: 10.5px; + font-weight: 600; +} + +/* ─── View Rate Chart button ─────────────────────────────────────────── */ +.rh-chart-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px; + border: 1px solid $border; + border-radius: 999px; + background: #fff; + color: $accent; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover { + background: $accent-soft; + border-color: $accent; + } +} + +/* ─── Stat grid ──────────────────────────────────────────────────────── */ +.rh-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +@media (width <= 640px) { + .rh-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.rh-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 9px 12px; + background: $soft; + border-radius: 10px; + + &__label { + font-size: 10px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__value { + font-size: 13.5px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + + &--positive { + color: $positive; + } + } + + &--highlight { + background: $accent-soft; + + .rh-stat__value { + color: $accent; + } + } +} + +/* ─── Empty state ────────────────────────────────────────────────────── */ +.rh-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 64px 16px; + color: $muted; + + &__icon { + font-size: 30px; + opacity: 0.4; + } + + &__text { + margin: 0; + font-size: 13px; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts new file mode 100644 index 000000000..34bd1bb2b --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts @@ -0,0 +1,235 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { Currency } from 'app/shared/models/general.model'; +import { CurrencyPipe } from '@angular/common'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ProductsService } from 'app/products/products.service'; +import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component'; + +/** + * Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab + * rather than a card on General, since a long-lived FD can carry many terms and each one's full detail + * (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably. + */ +@Component({ + selector: 'mifosx-renewal-history-tab', + templateUrl: './renewal-history-tab.component.html', + styleUrls: ['./renewal-history-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class RenewalHistoryTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private productsService = inject(ProductsService); + + fixedDepositsAccountData: any; + currency: Currency; + + /** + * The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once + * from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the + * premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history + * carries a senior-citizen column, since that concept only exists on the base rate master. + */ + private activeBaseSlabs: any[] = []; + + constructor() { + this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { + this.fixedDepositsAccountData = data.fixedDepositsAccountData; + this.currency = this.fixedDepositsAccountData.currency; + }); + } + + ngOnInit(): void { + const fdType = this.fixedDepositsAccountData?.fdType; + if (!fdType) { + return; + } + this.productsService.getFdBaseInterestRates(fdType).subscribe({ + next: (rates: any[]) => { + const active = (rates || []).find((r: any) => r.isActive); + this.activeBaseSlabs = active?.slabs ?? []; + }, + error: () => {} + }); + } + + /** + * The published senior-citizen rate for a tenure band, matched by period type and range against the active + * base rate card. Returns null when nothing matches (no published base version for this FD type, or this + * particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank + * rather than a misleading zero. + */ + private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null { + const match = this.activeBaseSlabs.find( + (s) => + s.periodTypeEnum === periodTypeEnum && + s.fromPeriod === fromPeriod && + (s.toPeriod ?? null) === (toPeriod ?? null) + ); + return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null; + } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + siblingLink(targetAccountId: number): string | null { + return this.siblingAccountLink(targetAccountId); + } + + /** + * Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they + * arrive straight from the SQL result-set column labels. + */ + get renewalHistory(): any[] { + return this.fixedDepositsAccountData?.renewalHistory ?? []; + } + + get fdNumber(): string { + return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo; + } + + /** The very first term's opening principal -- what the customer originally deposited. */ + get originalPrincipal(): number | null { + return this.renewalHistory[0]?.depositamount ?? null; + } + + /** The latest term's settled or projected value -- what the FD is worth right now. */ + get currentValue(): number | null { + const latest = this.renewalHistory[this.renewalHistory.length - 1]; + return latest ? this.maturityFor(latest) : null; + } + + /** + * Total interest earned across every term, settled terms plus the running term's interest-to-date. Null + * only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero". + */ + get lifetimeInterestEarned(): number | null { + if (!this.renewalHistory.length) { + return null; + } + let total = 0; + for (const term of this.renewalHistory) { + if (term.interestearned != null) { + total += Number(term.interestearned); + } else { + const projected = this.maturityFor(term); + if (projected != null && term.depositamount != null) { + total += Number(projected) - Number(term.depositamount); + } + } + } + return total; + } + + /** + * A term's settled maturity amount, or its projection while it is still running. Resolved here rather + * than inline so the choice isn't left to operator precedence between `??` and the currency pipe. + */ + maturityFor(term: any): number | null { + return term?.maturedamount ?? term?.projectedmaturityamount ?? null; + } + + /** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */ + private static readonly PERIOD_TYPE_LABELS: Record = { + 0: 'Days', + 1: 'Weeks', + 2: 'Months', + 3: 'Years' + }; + + /** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */ + tenureLabel(term: any): string { + if (term?.tenure == null) { + return ''; + } + const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? ''; + return unit ? `${term.tenure} ${unit}` : `${term.tenure}`; + } + + /** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */ + renewalTypeLabel(term: any): string | null { + if (term?.renewaltype === 'AUTO') return 'Auto-Renewed'; + if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff'; + return null; + } + + renewalTypeIcon(term: any): string { + return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot'; + } + + /** + * The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source. + * + * A term's chart is only archived once it closes via an in-place renewal, so the term still running has no + * archived rows -- for that one term (recognisable as the one matching this account's own id), the + * account's own live chart is shown instead, since it is the same data. The two sources use different key + * casing and represent the period type differently (a raw enum int here, an expanded {value} object on the + * live chart), which this maps into one consistent shape the dialog can render without knowing which source + * it came from. + */ + private chartSlabsFor(term: any): any[] { + const raw = term?.chartSlabs?.length + ? term.chartSlabs + : term?.accountid === this.fixedDepositsAccountData?.id + ? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? []) + : []; + return raw.map((slab: any) => { + const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum; + const fromPeriod = slab.fromperiod ?? slab.fromPeriod; + const toPeriod = slab.toperiod ?? slab.toPeriod; + // The archived/live row's own senior_citizen_rate is the true figure as published when this term + // was actually priced -- preferred whenever present. The live lookup against today's base rate + // card is only a fallback, for the currently-running term (not archived yet) or an older archive + // row written before that column existed -- flagged as such so the dialog can say so. + const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null; + return { + description: slab.description, + fromPeriod, + toPeriod, + periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '', + annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate, + seniorCitizenRate: + persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null), + seniorCitizenRateIsLive: persistedSeniorRate == null + }; + }); + } + + /** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */ + openRateChart(term: any): void { + this.dialog.open(TermRateChartDialogComponent, { + width: '560px', + data: { + term, + slabs: this.chartSlabsFor(term), + isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html new file mode 100644 index 000000000..7eadb1179 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html @@ -0,0 +1,77 @@ + + +
+

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

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

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

+ } + } @else { +
+ +

No rate chart was archived for this term.

+
+ } +
+ +
+ +
+ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss new file mode 100644 index 000000000..a0f4ec851 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss @@ -0,0 +1,159 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; + +$accent: #b8860b; + +.trc-dialog { + min-width: 420px; + max-width: 540px; +} + +.trc-header { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 4px; + + &__icon { + flex: none; + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 15px; + } + + &__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + line-height: 1.3; + } + + &__sub { + font-size: 12px; + color: $muted; + font-weight: 500; + } +} + +.trc-content { + padding-top: 4px; +} + +.trc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + padding: 8px 10px; + background: $soft; + color: $muted; + font-weight: 600; + text-align: left; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + color: $slate; + border-bottom: 1px solid $border; + font-variant-numeric: tabular-nums; + } + + &__rate-col { + text-align: right; + } + + &__row--applied { + background: $positive-soft; + + td { + color: $positive; + font-weight: 700; + } + } + + &__sc-ico { + color: $accent; + margin-right: 4px; + } + + &__sc-col { + color: $accent; + font-weight: 600; + } +} + +.trc-applied-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + background: $positive; + color: #fff; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + vertical-align: middle; +} + +.trc-note { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 10px 2px 0; + color: $muted; + font-size: 11px; + font-style: italic; + line-height: 1.4; +} + +.trc-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px 8px; + color: $muted; + text-align: center; + + &__icon { + font-size: 22px; + opacity: 0.5; + } + + p { + margin: 0; + font-size: 13px; + } +} + +.trc-actions { + padding-top: 6px; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts new file mode 100644 index 000000000..3ce7957f9 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts @@ -0,0 +1,93 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the + * timeline: a chart can run to several tenure bands, and showing it in place would push every later term + * down the page every time one is opened. + */ +@Component({ + selector: 'mifosx-term-rate-chart-dialog', + templateUrl: './term-rate-chart-dialog.component.html', + styleUrls: ['./term-rate-chart-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent + ] +}) +export class TermRateChartDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + get term(): any { + return this.data.term; + } + + get slabs(): any[] { + return this.data.slabs ?? []; + } + + get termLabel(): string { + return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno; + } + + /** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */ + get isSeniorCitizen(): boolean { + return !!this.data.isSeniorCitizen; + } + + /** + * True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the + * true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an + * older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all. + */ + get hasLiveSeniorRate(): boolean { + return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null); + } + + /** + * True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen + * term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}. + * Compared as numbers since one side arrives as a string from the currency-agnostic archive row. + */ + isAppliedSlab(slab: any): boolean { + return ( + !this.isSeniorCitizen && + this.term?.interestrate != null && + Number(slab.annualInterestRate) === Number(this.term.interestrate) + ); + } + + /** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */ + isAppliedSeniorSlab(slab: any): boolean { + return ( + this.isSeniorCitizen && + slab.seniorCitizenRate != null && + this.term?.interestrate != null && + Number(slab.seniorCitizenRate) === Number(this.term.interestrate) + ); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index ae4dafdf5..6e447a59c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -36,7 +36,11 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.id }} + @if (transaction.isRenewalEvent) { + + } @else { + {{ transaction.id }} + } @@ -72,7 +76,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -84,7 +94,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : !isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -96,36 +112,40 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.runningBalance | formatNumber }} + {{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }} {{ 'labels.inputs.Actions' | translate }} - - - - @if (!isFirstTransaction(idx)) { - - } - - + @if (!isFirstTransaction(idx)) { + + } + + + } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss index 44062e84c..57358a351 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss @@ -97,4 +97,32 @@ table { color: $muted; font-style: italic; } + + .mat-mdc-cell.renewal-marker { + background: #eff6ff; + color: $blue; + font-style: italic; + cursor: default; + } + + .mat-mdc-row:has(.renewal-marker):hover { + background: #eff6ff; + cursor: default; + } +} + +.txn-tab__renewal-ico { + color: $blue; +} + +.txn-tab__renewal-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: #dbeafe; + color: $blue; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts index 5caa3e47e..07691eaa6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts @@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit { */ constructor() { this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { - this.transactionsData = data.fixedDepositsAccountData.transactions; + this.transactionsData = this.withRenewalMarkers( + data.fixedDepositsAccountData.transactions, + data.fixedDepositsAccountData.renewalHistory + ); this.clientId = this.route.parent.snapshot.params['clientId']; this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; this.status = data.fixedDepositsAccountData.status.value; }); } + /** + * An in-place renewal moves no money -- the matured balance simply becomes the new term's opening + * principal -- so it posts no withdrawal, deposit, or journal entry (see + * DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal + * would be invisible in this list even though the account's rate and term just changed. These rows + * are synthesized purely for display from the renewal history already loaded for the Renewal History + * tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry + * lookup, and every real transaction's running balance stay exactly as they were. + */ + private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] { + const markers: any[] = (renewalHistory ?? []) + .filter((term: any) => term.renewalcycleno > 0) + .map((term: any) => { + const marker: any = { + isRenewalEvent: true, + id: null, + date: term.activatedondate, + amount: null, + runningBalance: null, + reversed: false, + transfer: null, + renewalCycleNo: term.renewalcycleno, + transactionType: { + value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed', + accrual: false, + withdrawal: false, + feeDeduction: false, + overdraftInterest: false, + withholdTax: false + } + }; + return marker; + }); + return [ + ...(transactions ?? []), + ...markers + ].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date)); + } + + /** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */ + private toEpoch(value: any): number { + if (!value) { + return 0; + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + ngOnInit() { this.hideAccrualsParam = new UntypedFormControl(false); this.hideReversedParam = new UntypedFormControl(false); @@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit { * @param {any} transactionsData Transactions Data */ showTransactions(transactionsData: any) { + if (transactionsData.isRenewalEvent) { + return; + } if (transactionsData.transfer) { this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], { relativeTo: this.route @@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit { this.dataSource.sort = this.sort; } - savingsTransactionColor(transaction: SavingsAccountTransaction): string { + savingsTransactionColor(transaction: any): string { + if (transaction.isRenewalEvent) { + return 'renewal-marker'; + } if (transaction.reversed) { return 'strike'; } else if (transaction.transfer) { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts index 68861fff9..eb2f6e5c5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts @@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver'; import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver'; import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component'; +import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component'; import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component'; const routes: Routes = [ @@ -80,6 +81,15 @@ const routes: Routes = [ routeParamBreadcrumb: false } }, + { + path: 'renewal-history', + component: RenewalHistoryTabComponent, + data: { + title: 'Fixed Deposit Account Renewal History', + breadcrumb: 'Renewal History', + routeParamBreadcrumb: false + } + }, { path: 'transactions', component: TransactionsTabComponent, diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index 6d20ed276..dd82ede92 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -87,6 +87,17 @@ export class FixedDepositsService { return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); } + /** + * Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also + * include Partial Re-Invest, which the closure commands exclude because only Payment implements it. + * @param {string} accountId Fixed Deposits Account Id + * @returns {Observable} + */ + getFixedDepositsAccountPaymentTemplate(accountId: string): Observable { + const httpParams = new HttpParams().set('command', 'payment'); + return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); + } + /** * @param {string} accountId Fixed Deposits Account Id * @returns {Observable} diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html index e2862f74f..f5d32347b 100644 --- a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -36,7 +36,7 @@
Account No. - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }}
Date of Issue @@ -49,9 +49,13 @@ This is to certify that {{ accountData.clientName || accountData.groupName }} holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }} with Nexora Co-operative Bank Ltd., as per the particulars set out below. + + This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the + full renewal history is set out below. +

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