WHAT THIS CHANGES
==================
Frontend counterpart to the backend FD in-place renewal, senior-citizen
rate, and Payment action work.
1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/.
A vertical timeline, one card per term of the FD, oldest first. Backend
data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL
aliases), which the component normalises. Includes term-rate-chart-dialog,
a popup showing one term's full rate-by-tenure chart, including the
senior-citizen column, with an "Applied" badge on the rate that was
actually used. Route added in fixed-deposits-routing.module.ts, tab
link added in fixed-deposit-account-view.component.html (shown only
when the account has renewal history).
2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/.
Card-based picker for the four supported closure types (Transfer to
Savings, Re-Invest Principal and Interest, Re-Invest Principal Only,
Partial Re-Invest), with Withdraw Deposit filtered out client-side.
Fetches its own template via a new fixedDepositsService method
(getFixedDepositsAccountPaymentTemplate, command=payment) rather than
reusing the Close template, since the Payment template is the only one
the backend includes PARTIAL_REINVEST in.
The Partial Re-Invest amount cap is date-dependent: the backend bounds
it against the account balance AFTER posting interest to the chosen
closed-on date, which lands on the maturity amount on or after
maturity and on the current balance before it. The dialog mirrors this
(reinvestCap / processingOnOrAfterMaturity) so the shown split actually
adds up and the client-side validator agrees with the server. This was
wrong in an earlier pass of this work (compared against a fixed figure
regardless of date) and was caught and fixed via a real reconciliation
check (77000 + 23000 did not equal the displayed total) before this
commit.
Added: a loading-failure state (previously the spinner ran forever on
a failed template fetch), an inline submit-error message (previously a
failed POST left the dialog open with no feedback), an empty-savings-
accounts hint, and accessibility attributes on the option cards
(role="radio", aria-checked, a visible check icon, focus-visible
outline) since selection was previously colour-only.
3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the
real transaction list, since an in-place renewal posts no actual
ledger transaction and would otherwise look like the balance changed
for no reason.
4. INTEREST RATE CHART TAB -- added the senior-citizen rate column.
5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD
certificates. renewalCount is renewalHistory.length - 1 (the first
row is the original term, not a renewal); an earlier version of this
used the raw row count and overstated the renewal count by one on a
customer-facing document, caught and fixed before this commit.
6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed
FD's superseded terms out of the account list (each renewal used to
show as two unrelated accounts, one Closed and one Active). Memoised
on the source array's identity so it does not rebuild, and rebind the
table, on every change-detection tick. Only rewrites FD rows; Savings,
RD, Current, and Share account rows pass through this getter
untouched, and the RD/Savings sections of the template were left
bound directly to the original savingAccounts array.
KNOWN CAVEATS
=============
- This depends on the paired backend commit (same date, "feat(deposits):
FD in-place renewal, senior-citizen rate persistence, and Payment
action"). Deploying this frontend without that backend change means
the Payment dialog and Renewal History tab will call endpoints that
do not exist yet.
- Renewal-history rows rely on the backend returning lowercase column
keys (a Postgres-specific behaviour of unquoted SQL aliases via
JdbcTemplate). Confirmed correct against this project's Postgres
dev database; if this project ever runs on a different database
engine, that assumption would need re-checking.
- src/environments/.env.ts is intentionally excluded from this commit;
it is a local build-version stamp file that should never be
committed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
/**
|
|
* 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 { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
|
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
|
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
|
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
|
|
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
|
|
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
|
|
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
|
|
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
|
|
|
|
/** Custom Button Config. */
|
|
import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config';
|
|
|
|
/** Custom Services */
|
|
import { FixedDepositsService } from '../fixed-deposits.service';
|
|
import { SavingsService } from 'app/savings/savings.service';
|
|
import { AccountsService } from 'app/accounts/accounts.service';
|
|
import { Currency } from 'app/shared/models/general.model';
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { Dates } from 'app/core/utils/dates';
|
|
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { NgClass, CurrencyPipe } from '@angular/common';
|
|
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
|
import { LongTextComponent } from '../../../shared/long-text/long-text.component';
|
|
import { MatIconButton } from '@angular/material/button';
|
|
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
|
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
|
|
|
/**
|
|
* Fixed Deposits Account View Component
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-fixed-deposit-account-view',
|
|
templateUrl: './fixed-deposit-account-view.component.html',
|
|
styleUrls: ['./fixed-deposit-account-view.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatTooltip,
|
|
NgClass,
|
|
LongTextComponent,
|
|
AccountNumberComponent,
|
|
MatIconButton,
|
|
MatMenuTrigger,
|
|
FaIconComponent,
|
|
MatMenu,
|
|
MatMenuItem,
|
|
MatTabNav,
|
|
MatTabLink,
|
|
RouterLinkActive,
|
|
MatTabNavPanel,
|
|
RouterOutlet,
|
|
CurrencyPipe,
|
|
StatusLookupPipe,
|
|
DormancyAlertPanelComponent
|
|
]
|
|
})
|
|
export class FixedDepositAccountViewComponent implements OnInit {
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private fixedDepositsService = inject(FixedDepositsService);
|
|
private savingsService = inject(SavingsService);
|
|
private accountsService = inject(AccountsService);
|
|
private settingsService = inject(SettingsService);
|
|
private dateUtils = inject(Dates);
|
|
private authenticationService = inject(AuthenticationService);
|
|
dialog = inject(MatDialog);
|
|
|
|
/** Fixed Deposits Account Data */
|
|
fixedDepositsAccountData: any;
|
|
/** Savings Data Tables */
|
|
savingsDatatables: any;
|
|
/** Button Configurations */
|
|
buttonConfig: FixedDepositsButtonsConfiguration;
|
|
/** Entity Type */
|
|
entityType: string;
|
|
currency: Currency;
|
|
showTransactions = false;
|
|
|
|
private readonly ACTION_KIND: Record<string, 'transact' | 'block' | 'interest' | 'admin' | 'danger'> = {
|
|
Deposit: 'transact',
|
|
Withdrawal: 'transact',
|
|
'Transfer Funds': 'transact',
|
|
'Calculate Interest': 'interest',
|
|
'Post Interest': 'interest',
|
|
'Enable Withhold Tax': 'interest',
|
|
'Disable Withhold Tax': 'interest',
|
|
Payment: 'transact',
|
|
'Premature Close': 'danger',
|
|
'Undo Activation': 'admin',
|
|
Close: 'danger',
|
|
'Modify Application': 'admin',
|
|
Approve: 'admin',
|
|
'Undo Approval': 'admin',
|
|
Activate: 'admin',
|
|
'Add Charge': 'admin',
|
|
'Apply Annual Fees': 'admin',
|
|
Reject: 'danger',
|
|
'Withdrawn by Client': 'danger',
|
|
Delete: 'danger',
|
|
Terminate: 'danger'
|
|
};
|
|
|
|
private readonly ACTION_ICON: Record<string, string> = {
|
|
Deposit: 'arrow-down',
|
|
Withdrawal: 'arrow-up',
|
|
'Transfer Funds': 'exchange-alt',
|
|
'Calculate Interest': 'calculator',
|
|
'Post Interest': 'percentage',
|
|
'Enable Withhold Tax': 'percentage',
|
|
'Disable Withhold Tax': 'percentage',
|
|
Payment: 'sync-alt',
|
|
'Premature Close': 'times-circle',
|
|
'Undo Activation': 'undo',
|
|
Close: 'times-circle',
|
|
'Modify Application': 'edit',
|
|
Approve: 'check-circle',
|
|
'Undo Approval': 'undo',
|
|
Activate: 'check',
|
|
'Add Charge': 'plus-circle',
|
|
'Apply Annual Fees': 'receipt',
|
|
Reject: 'ban',
|
|
'Withdrawn by Client': 'user-slash',
|
|
Delete: 'trash',
|
|
Terminate: 'times-circle'
|
|
};
|
|
|
|
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
|
|
return this.ACTION_KIND[name] ?? 'admin';
|
|
}
|
|
|
|
iconOf(name: string, fallback?: string): string {
|
|
return this.ACTION_ICON[name] ?? fallback ?? 'circle';
|
|
}
|
|
|
|
hasKind(kind: 'transact' | 'block' | 'interest' | 'admin' | 'danger'): boolean {
|
|
const all = [
|
|
...(this.buttonConfig?.singleButtons ?? []),
|
|
...(this.buttonConfig?.options ?? [])
|
|
];
|
|
return all.some((item) => this.kindOf(item.name) === kind);
|
|
}
|
|
|
|
/**
|
|
* Fetches fixed deposits account data from `resolve`
|
|
* @param {ActivatedRoute} route Activated Route
|
|
* @param {Router} router Router
|
|
* @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service
|
|
* @param {SavingsService} savingsService Savings Service
|
|
* @param {MatDialog} dialog Mat Dialog
|
|
*/
|
|
constructor() {
|
|
this.route.data.subscribe((data: { fixedDepositsAccountData: any; savingsDatatables: any }) => {
|
|
this.fixedDepositsAccountData = data.fixedDepositsAccountData;
|
|
this.savingsDatatables = data.savingsDatatables;
|
|
this.currency = this.fixedDepositsAccountData.currency;
|
|
const status: any = data.fixedDepositsAccountData.status;
|
|
this.showTransactions = status.id >= 300;
|
|
});
|
|
if (this.router.url.includes('clients')) {
|
|
this.entityType = 'Client';
|
|
} else if (this.router.url.includes('groups')) {
|
|
this.entityType = 'Group';
|
|
} else if (this.router.url.includes('centers')) {
|
|
this.entityType = 'Center';
|
|
}
|
|
}
|
|
|
|
ngOnInit() {
|
|
this.setConditionalButtons();
|
|
}
|
|
|
|
/**
|
|
* Adds options to button config. conditionaly.
|
|
*/
|
|
setConditionalButtons() {
|
|
const status = this.fixedDepositsAccountData.status.value;
|
|
this.buttonConfig = new FixedDepositsButtonsConfiguration(status);
|
|
if (this.fixedDepositsAccountData.taxGroup && status === 'Active') {
|
|
if (this.fixedDepositsAccountData.withHoldTax) {
|
|
this.buttonConfig.addOption({
|
|
name: 'Disable Withhold Tax'
|
|
});
|
|
} else {
|
|
this.buttonConfig.addOption({
|
|
name: 'Enable Withhold Tax'
|
|
});
|
|
}
|
|
}
|
|
if (!this.authenticationService.hasCashierRole()) {
|
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
|
);
|
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refetches data fot the component
|
|
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
|
|
*/
|
|
reload() {
|
|
const clientId = this.fixedDepositsAccountData.clientId;
|
|
const url: string = this.router.url;
|
|
this.router
|
|
.navigateByUrl(`/clients/${clientId}/fixed-deposits-accounts`, { skipLocationChange: true })
|
|
.then(() => this.router.navigate([url]));
|
|
}
|
|
|
|
/**
|
|
* Performs action button/option action.
|
|
* @param {string} name action name.
|
|
*/
|
|
doAction(name: string) {
|
|
switch (name) {
|
|
case 'Approve':
|
|
case 'Reject':
|
|
case 'Activate':
|
|
case 'Close':
|
|
case 'Undo Approval':
|
|
case 'Undo Activation':
|
|
case 'Add Charge':
|
|
case 'Withdrawn by Client':
|
|
case 'Premature Close':
|
|
case 'Deposit':
|
|
case 'Withdrawal':
|
|
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
|
|
break;
|
|
case 'Modify Application':
|
|
this.router.navigate(['edit'], { relativeTo: this.route });
|
|
break;
|
|
case 'Delete':
|
|
this.deleteFixedDepositsAccount();
|
|
break;
|
|
case 'Calculate Interest':
|
|
this.calculateInterest();
|
|
break;
|
|
case 'Post Interest':
|
|
this.postInterest();
|
|
break;
|
|
case 'Enable Withhold Tax':
|
|
this.enableWithHoldTax();
|
|
break;
|
|
case 'Disable Withhold Tax':
|
|
this.disableWithHoldTax();
|
|
break;
|
|
case 'Terminate':
|
|
this.terminateFixedDepositsAccount();
|
|
break;
|
|
case 'Payment':
|
|
this.openPaymentDialog();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
|
|
* Matured, and is the only place a Partial Reinvest can be done.
|
|
*/
|
|
private openPaymentDialog(): void {
|
|
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
|
|
width: '640px',
|
|
maxWidth: '95vw',
|
|
data: {
|
|
accountId: this.fixedDepositsAccountData.id,
|
|
// The server bounds a partial reinvest by the balance AFTER it posts interest to the chosen date,
|
|
// so the dialog needs both figures plus the maturity date to know which one that will be.
|
|
accountBalance: this.fixedDepositsAccountData.summary?.accountBalance,
|
|
maturityDate: this.fixedDepositsAccountData.maturityDate
|
|
}
|
|
});
|
|
paymentDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response?.confirm) {
|
|
this.reload();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes Fixed Deposits Account.
|
|
*/
|
|
private deleteFixedDepositsAccount() {
|
|
const deleteFixedDepositsAccountDialogRef = this.dialog.open(DeleteDialogComponent, {
|
|
data: { deleteContext: `fixed deposit account with id: ${this.fixedDepositsAccountData.id}` }
|
|
});
|
|
deleteFixedDepositsAccountDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.delete) {
|
|
this.fixedDepositsService.deleteFixedDepositsAccount(this.fixedDepositsAccountData.id).subscribe(() => {
|
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private terminateFixedDepositsAccount() {
|
|
const terminateFixedDepositAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
|
data: {
|
|
heading: 'Terminate Account',
|
|
dialogContext: 'Are you sure you want to terminate this account?',
|
|
type: 'Strong'
|
|
}
|
|
});
|
|
terminateFixedDepositAccountDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response && response.confirm) {
|
|
const locale = this.settingsService.language.code;
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
|
|
const data = {
|
|
accountType: 'FD',
|
|
terminatedOnDate,
|
|
dateFormat,
|
|
locale
|
|
};
|
|
this.accountsService.terminateAccount(this.fixedDepositsAccountData.id, data).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculates fixed deposit account's interest
|
|
*/
|
|
private calculateInterest() {
|
|
const calculateInterestAccountDialogRef = this.dialog.open(CalculateInterestDialogComponent);
|
|
calculateInterestAccountDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.confirm) {
|
|
this.fixedDepositsService
|
|
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'calculateInterest', {})
|
|
.subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Posts fixed deposit account's interest
|
|
*/
|
|
private postInterest() {
|
|
const postInterestAccountDialogRef = this.dialog.open(PostInterestDialogComponent);
|
|
postInterestAccountDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.confirm) {
|
|
this.fixedDepositsService
|
|
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
|
|
.subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enables withhold tax for fixed deposits account.
|
|
* Fixed deposits endpoint is not supported so using Savings endpoint.
|
|
*/
|
|
private enableWithHoldTax() {
|
|
const deleteSavingsAccountDialogRef = this.dialog.open(ToggleWithholdTaxDialogComponent, {
|
|
data: { isEnable: true }
|
|
});
|
|
deleteSavingsAccountDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.confirm) {
|
|
this.savingsService
|
|
.executeSavingsAccountUpdateCommand(this.fixedDepositsAccountData.id, 'updateWithHoldTax', {
|
|
withHoldTax: true
|
|
})
|
|
.subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private disableWithHoldTax() {
|
|
const disableWithHoldTaxDialogRef = this.dialog.open(ToggleWithholdTaxDialogComponent, {
|
|
data: { isEnable: false }
|
|
});
|
|
disableWithHoldTaxDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.confirm) {
|
|
this.savingsService
|
|
.executeSavingsAccountUpdateCommand(this.fixedDepositsAccountData.id, 'updateWithHoldTax', {
|
|
withHoldTax: false
|
|
})
|
|
.subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|