Files
cbsnexora/src/app/clients/clients-view/general-tab/general-tab.component.ts
T
nitesh f0e059c621
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
feat(deposits): FD Renewal History tab, redesigned Payment action, and renewal-aware Transactions/certificate
WHAT THIS CHANGES
==================
Frontend counterpart to the backend FD in-place renewal, senior-citizen
rate, and Payment action work.

1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/.
   A vertical timeline, one card per term of the FD, oldest first. Backend
   data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL
   aliases), which the component normalises. Includes term-rate-chart-dialog,
   a popup showing one term's full rate-by-tenure chart, including the
   senior-citizen column, with an "Applied" badge on the rate that was
   actually used. Route added in fixed-deposits-routing.module.ts, tab
   link added in fixed-deposit-account-view.component.html (shown only
   when the account has renewal history).

2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/.
   Card-based picker for the four supported closure types (Transfer to
   Savings, Re-Invest Principal and Interest, Re-Invest Principal Only,
   Partial Re-Invest), with Withdraw Deposit filtered out client-side.
   Fetches its own template via a new fixedDepositsService method
   (getFixedDepositsAccountPaymentTemplate, command=payment) rather than
   reusing the Close template, since the Payment template is the only one
   the backend includes PARTIAL_REINVEST in.
   The Partial Re-Invest amount cap is date-dependent: the backend bounds
   it against the account balance AFTER posting interest to the chosen
   closed-on date, which lands on the maturity amount on or after
   maturity and on the current balance before it. The dialog mirrors this
   (reinvestCap / processingOnOrAfterMaturity) so the shown split actually
   adds up and the client-side validator agrees with the server. This was
   wrong in an earlier pass of this work (compared against a fixed figure
   regardless of date) and was caught and fixed via a real reconciliation
   check (77000 + 23000 did not equal the displayed total) before this
   commit.
   Added: a loading-failure state (previously the spinner ran forever on
   a failed template fetch), an inline submit-error message (previously a
   failed POST left the dialog open with no feedback), an empty-savings-
   accounts hint, and accessibility attributes on the option cards
   (role="radio", aria-checked, a visible check icon, focus-visible
   outline) since selection was previously colour-only.

3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the
   real transaction list, since an in-place renewal posts no actual
   ledger transaction and would otherwise look like the balance changed
   for no reason.

4. INTEREST RATE CHART TAB -- added the senior-citizen rate column.

5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD
   certificates. renewalCount is renewalHistory.length - 1 (the first
   row is the original term, not a renewal); an earlier version of this
   used the raw row count and overstated the renewal count by one on a
   customer-facing document, caught and fixed before this commit.

6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed
   FD's superseded terms out of the account list (each renewal used to
   show as two unrelated accounts, one Closed and one Active). Memoised
   on the source array's identity so it does not rebuild, and rebind the
   table, on every change-detection tick. Only rewrites FD rows; Savings,
   RD, Current, and Share account rows pass through this getter
   untouched, and the RD/Savings sections of the template were left
   bound directly to the original savingAccounts array.

KNOWN CAVEATS
=============
  - This depends on the paired backend commit (same date, "feat(deposits):
    FD in-place renewal, senior-citizen rate persistence, and Payment
    action"). Deploying this frontend without that backend change means
    the Payment dialog and Renewal History tab will call endpoints that
    do not exist yet.
  - Renewal-history rows rely on the backend returning lowercase column
    keys (a Postgres-specific behaviour of unquoted SQL aliases via
    JdbcTemplate). Confirmed correct against this project's Postgres
    dev database; if this project ever runs on a different database
    engine, that assumption would need re-checking.
  - src/environments/.env.ts is intentionally excluded from this commit;
    it is a local build-version stamp file that should never be
    committed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:46:22 +05:30

486 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, OnDestroy, inject } from '@angular/core';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
/** Custom Services. */
import { ClientsService } from 'app/clients/clients.service';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatFooterCellDef,
MatFooterCell,
MatFooterRowDef,
MatFooterRow
} from '@angular/material/table';
import { NgClass } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
import { LongTextComponent } from '../../../shared/long-text/long-text.component';
import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { AccountsFilterPipe } from '../../../pipes/accounts-filter.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { CurrencyPipe } from '@angular/common';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReportsService } from 'app/reports/reports.service';
import { SettingsService } from 'app/settings/settings.service';
import { Subject } from 'rxjs';
import { takeUntil, catchError } from 'rxjs/operators';
import { AlertService } from 'app/core/alert/alert.service';
import { EMPTY } from 'rxjs';
/**
* General Tab component.
*/
@Component({
selector: 'mifosx-general-tab',
templateUrl: './general-tab.component.html',
styleUrls: ['./general-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
NgClass,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatFooterCellDef,
MatFooterCell,
MatFooterRowDef,
MatFooterRow,
AccountNumberComponent,
LongTextComponent,
MatTooltip,
StatusLookupPipe,
AccountsFilterPipe,
DateFormatPipe,
FormatNumberPipe,
CurrencyPipe
]
})
export class GeneralTabComponent implements OnDestroy {
private destroy$ = new Subject<void>();
private alertService = inject(AlertService);
private sanitizer = inject(DomSanitizer);
pdfUrl: SafeResourceUrl | null = null;
rawPdfUrl: string | null = null;
showPdf: boolean = false;
openLoanApplicationReport(event: MouseEvent, loanId: string) {
event.stopPropagation();
const tenantIdentifier = this.settingsService.tenantIdentifier || 'default';
let locale = this.settingsService.languageCode || 'en-US';
locale = locale.split('-')[0];
const dateFormat = this.settingsService.dateFormat || 'dd MMMM yyyy';
const formData = { R_loanId: loanId, 'output-type': 'PDF' };
this.reportsService
.getPentahoRunReportData('LoanApplicationReport', formData, tenantIdentifier, locale, dateFormat)
.pipe(
takeUntil(this.destroy$),
catchError((error): any => {
this.showPdf = false;
if (this.rawPdfUrl) {
URL.revokeObjectURL(this.rawPdfUrl);
this.rawPdfUrl = null;
}
this.pdfUrl = null;
this.alertService.alert({
type: 'error',
message: 'Failed to load Loan Application PDF report.'
});
return EMPTY;
})
)
.subscribe((res: any) => {
if (this.rawPdfUrl) {
URL.revokeObjectURL(this.rawPdfUrl);
this.rawPdfUrl = null;
this.pdfUrl = null;
}
const contentType = res.headers.get('Content-Type') || 'application/pdf';
const file = new Blob([res.body], { type: contentType });
this.rawPdfUrl = URL.createObjectURL(file);
this.pdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.rawPdfUrl);
this.showPdf = true;
});
}
closePdf() {
this.showPdf = false;
if (this.rawPdfUrl) {
URL.revokeObjectURL(this.rawPdfUrl);
this.rawPdfUrl = null;
}
this.pdfUrl = null;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
if (this.rawPdfUrl) {
URL.revokeObjectURL(this.rawPdfUrl);
this.rawPdfUrl = null;
}
this.pdfUrl = null;
}
private route = inject(ActivatedRoute);
private clientService = inject(ClientsService);
private router = inject(Router);
private reportsService = inject(ReportsService);
private settingsService = inject(SettingsService);
/** Open Loan Accounts Columns */
openLoansColumns: string[] = [
'Account No',
'Loan Account',
'Original Loan',
'Loan Balance',
'Amount Paid',
'Type',
'Actions'
];
/** Closed Loan Accounts Columns */
closedLoansColumns: string[] = [
'Account No',
'Loan Account',
'Original Loan',
'Loan Balance',
'Amount Paid',
'Type',
'Closed Date',
'Actions'
];
/** Open Savings Accounts Columns */
openSavingsColumns: string[] = [
'Account No',
'Saving Account',
'Last Active',
'Balance',
'Actions'
];
/** Closed Savings Accounts Columns */
closedSavingsColumns: string[] = [
'Account No',
'Saving Account',
'Closed Date'
];
/** Open Shares Accounts Columns */
openSharesColumns: string[] = [
'Account No',
'Share Account',
'Approved Shares',
'Pending For Approval Shares',
'Actions'
];
/** Closed Shares Accounts Columns */
closedSharesColumns: string[] = [
'Account No',
'Share Account',
'Approved Shares',
'Pending For Approval Shares',
'Closed Date'
];
/** Upcoming Charges Columns */
upcomingChargesColumns: string[] = [
'Name',
'Due as of',
'Due',
'Paid',
'Waived',
'Outstanding',
'Actions'
];
/** Collaterals Column */
collateralsColumns: string[] = [
'ID',
'Name',
'Quantity',
'Total Value',
'Total Collateral Value'
];
/** Client Account Data */
clientAccountData: any;
/** Loan Accounts Data */
loanAccounts: any[] = [];
/** Savings Accounts Data (all: savings + FD + RD — filtered per section) */
savingAccounts: any[] = [];
get pureSavingsCount(): number {
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.savingsDeposit').length;
}
get fixedDepositCount(): number {
return this.fixedDepositAccountsForDisplay.filter(
(a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit'
).length;
}
/**
* Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away.
*
* An FD renewal closes one account and opens the next term as its own account row, so a renewed FD
* would otherwise appear twice on this page -- the matured term under Closed and the new term under
* Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later
* cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its
* current term. A chain that has genuinely ended keeps its final term, so it still lists as closed.
*
* Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched.
*/
get fixedDepositAccountsForDisplay(): any[] {
// Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so
// returning a freshly built array on every change-detection pass would make the table re-bind and
// re-render its rows on every tick -- mouse moves included.
if (this.fdDisplayCacheSource !== this.savingAccounts) {
this.fdDisplayCacheSource = this.savingAccounts;
this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts);
}
return this.fdDisplayCache;
}
private fdDisplayCacheSource: any[] = null;
private fdDisplayCache: any[] = [];
private foldSupersededFdCycles(accounts: any[]): any[] {
const latestCycleByFd = new Map<string, number>();
for (const account of accounts) {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
continue;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) {
latestCycleByFd.set(fdNumber, cycle);
}
}
return accounts.filter((account: any) => {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
return true;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
return cycle === latestCycleByFd.get(fdNumber);
});
}
get recurringDepositCount(): number {
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.recurringDeposit').length;
}
/** Current Accounts Data */
currentAccounts: any[] = [];
/** Shares Accounts Data */
shareAccounts: any[] = [];
/** Upcoming Charges Data */
upcomingCharges: any[] = [];
/** Performance History Data */
performanceHistory: {
loanCycle: number;
activeLoans: number;
lastLoanAmount: number;
activeSavings: number;
totalSavings: number;
} = {
loanCycle: 0,
activeLoans: 0,
lastLoanAmount: 0,
activeSavings: 0,
totalSavings: 0
};
/** Collaterals Data */
collaterals: any[] = [];
/** Show Closed Loan Accounts */
showClosedLoanAccounts = false;
/** Show Closed Saving Accounts */
showClosedSavingAccounts = false;
/** Show Closed Share Accounts */
showClosedShareAccounts = false;
/** Show Closed Reccuring Deposits Accounts */
showClosedRecurringAccounts = false;
/** Show Closed Fixed Deposits Accounts */
showClosedFixedAccounts = false;
/** Client Id */
clientid: any;
/**
* @param {ActivatedRoute} route Activated Route
* @param {ClientsService} clientService Clients Service
* @param {Router} router Router
*/
constructor() {
this.route.data.subscribe(
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
this.clientAccountData = data.clientAccountsData;
this.savingAccounts = data.clientAccountsData?.savingsAccounts ?? [];
this.currentAccounts = data.clientAccountsData?.currentAccounts ?? [];
this.loanAccounts = data.clientAccountsData?.loanAccounts ?? [];
this.shareAccounts = data.clientAccountsData?.shareAccounts ?? [];
this.upcomingCharges = data.clientChargesData?.pageItems ?? [];
this.collaterals = data.clientCollateralData ?? [];
this.clientid = this.route.parent.snapshot.params['clientId'];
// Compute performance history from accounts data
this.computePerformanceHistory(data.clientAccountsData ?? { loanAccounts: [], savingsAccounts: [] });
}
);
}
private computePerformanceHistory(accountsData: any) {
// Loan Cycles: total number of loans
const allLoans = accountsData.loanAccounts || [];
this.performanceHistory.loanCycle = allLoans.length;
// Active Loans: status === 'Active'
this.performanceHistory.activeLoans = allLoans.filter((l: any) => {
if (!l.status) return false;
if (typeof l.status === 'string') {
return l.status.toLowerCase() === 'active';
}
if (typeof l.status === 'object' && l.status.value) {
return l.status.value.toLowerCase() === 'active';
}
return false;
}).length;
// Loan Amount: most recent loan by submittedOnDate
if (allLoans.length > 0) {
const sortedLoans = [...allLoans].sort(
(a, b) => new Date(b.submittedOnDate).getTime() - new Date(a.submittedOnDate).getTime()
);
this.performanceHistory.lastLoanAmount = sortedLoans[0].principal || 0;
} else {
this.performanceHistory.lastLoanAmount = 0;
}
// Active Savings: status === 'Active'
const allSavings = accountsData.savingsAccounts || [];
this.performanceHistory.activeSavings = allSavings.filter((s: any) => {
if (!s.status) return false;
if (typeof s.status === 'string') {
return s.status.toLowerCase() === 'active';
}
if (typeof s.status === 'object' && s.status.value) {
return s.status.value.toLowerCase() === 'active';
}
return false;
}).length;
// Total Savings: sum of balances
this.performanceHistory.totalSavings = allSavings.reduce((sum: number, s: any) => sum + (s.accountBalance || 0), 0);
}
/**
* Toggles Loan Accounts Overview
*/
toggleLoanAccountsOverview() {
this.showClosedLoanAccounts = !this.showClosedLoanAccounts;
}
/**
* Toggles Loan Accounts Overview
*/
toggleSavingAccountsOverview() {
this.showClosedSavingAccounts = !this.showClosedSavingAccounts;
}
/**
* Toggles Loan Accounts Overview
*/
toggleShareAccountsOverview() {
this.showClosedShareAccounts = !this.showClosedShareAccounts;
}
/**
* Toggles Reccuring Accounts Overview
*/
toggleRecurringAccountsOverview() {
this.showClosedRecurringAccounts = !this.showClosedRecurringAccounts;
}
/**
* Toggles Fixed Accounts Overview
*/
toggleFixedAccountsOverview() {
this.showClosedFixedAccounts = !this.showClosedFixedAccounts;
}
/**
* Waive Charge.
* @param chargeId Selected Charge Id.
* @param clientId Selected Client Id.
*/
waiveCharge(chargeId: string, clientId: string) {
const charge = { clientId: clientId.toString(), resourceType: chargeId };
this.clientService.waiveClientCharge(charge).subscribe(() => {
this.getChargeData(clientId);
});
}
/**
* Get Charge Data.
* @param clientId Selected Client Id.
*/
getChargeData(clientId: string) {
this.clientService.getClientChargesData(clientId).subscribe((data: any) => {
this.upcomingCharges = data.pageItems;
});
}
/**
* Stops the propagation to view pages.
* @param $event Mouse Event
*/
routeEdit($event: MouseEvent) {
$event.stopPropagation();
}
/**
* @param {any} loanId Loan Id
*/
routeTransferFund(loanId: any) {
const queryParams: any = { loanId: loanId, accountType: 'fromloans' };
this.router.navigate(
[
'../',
'loans-accounts',
loanId,
'transfer-funds',
'make-account-transfer'
],
{ relativeTo: this.route, queryParams: queryParams }
);
}
viewAccountsLabel(closed: boolean): string {
if (closed) {
return 'labels.buttons.View Active Accounts';
} else {
return 'labels.buttons.View Closed Accounts';
}
}
}