feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming / On Track), computed client-side from maturityDate so it can never drift out of step with the account. Reads the new lean maturity-dashboard backend endpoint. A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to the account's own Payment dialog -- see the FD account-view commit for that side of it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { AccountsService } from '../accounts/accounts.service';
|
||||
import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { toIsoDate } from 'app/core/utils/api-date';
|
||||
|
||||
/** Dashboard data + computation */
|
||||
import {
|
||||
FD_MATURITY_POLICY,
|
||||
FD_MATURITY_STAGES,
|
||||
FdMaturityRow,
|
||||
FdMaturityStage,
|
||||
FdMaturitySummary,
|
||||
buildMaturityRow,
|
||||
maturitySummary,
|
||||
sortMaturityRows
|
||||
} from './fd-dashboard.compute';
|
||||
import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component';
|
||||
|
||||
/**
|
||||
* FD Dashboard: every active Fixed Deposit positioned against its own maturity date.
|
||||
*
|
||||
* Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due
|
||||
* Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this
|
||||
* screen can never drift out of step with the account -- it always says what today's date implies.
|
||||
* Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses;
|
||||
* nothing new was added on the backend for this.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-fd-dashboard',
|
||||
templateUrl: './fd-dashboard.component.html',
|
||||
styleUrls: ['./fd-dashboard.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class FdDashboardComponent implements OnInit {
|
||||
private accountsService = inject(AccountsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
rows: FdMaturityRow[] = [];
|
||||
|
||||
/** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards
|
||||
* against opening a second dialog for the same row while one is already up. */
|
||||
renewing: number | null = null;
|
||||
|
||||
/** Filters */
|
||||
stageFilter: FdMaturityStage | '' = '';
|
||||
search = '';
|
||||
|
||||
readonly policy = FD_MATURITY_POLICY;
|
||||
readonly stageMeta = FD_MATURITY_STAGES;
|
||||
/** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs
|
||||
* attention (matured or maturing soon), matching why this screen exists. */
|
||||
readonly stageKeys: FdMaturityStage[] = [
|
||||
'MATURED',
|
||||
'DUE_SOON',
|
||||
'UPCOMING',
|
||||
'ON_TRACK'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every active FD and re-derives the maturity ladder for each.
|
||||
*
|
||||
* Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource),
|
||||
* not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra
|
||||
* table joins for audit/currency/tax fields this screen never shows) and, until this feature
|
||||
* added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs
|
||||
* (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's
|
||||
* nothing to re-check client-side here.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
const today = toIsoDate(new Date().toISOString());
|
||||
|
||||
this.accountsService.getFixedDepositMaturityDashboard().subscribe({
|
||||
next: (res: any) => {
|
||||
const items: any[] = Array.isArray(res) ? res : [];
|
||||
|
||||
const mapped = items
|
||||
.map((a: any) => this.mapAccount(a))
|
||||
.map((a) => buildMaturityRow(a, today))
|
||||
.filter((r): r is FdMaturityRow => !!r);
|
||||
|
||||
this.rows = sortMaturityRows(mapped);
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */
|
||||
private mapAccount(a: any): {
|
||||
id: number;
|
||||
accountNo: string;
|
||||
clientId?: number;
|
||||
clientName?: string;
|
||||
productName?: string;
|
||||
savingsProductName?: string;
|
||||
fdType?: string;
|
||||
maturityDate: string;
|
||||
depositAmount?: number;
|
||||
maturityAmount?: number;
|
||||
onClosureType?: number;
|
||||
linkedAccountId?: number;
|
||||
renewalGracePeriodDays?: number;
|
||||
} {
|
||||
return {
|
||||
id: a?.id,
|
||||
accountNo: a?.accountNo || '',
|
||||
clientId: a?.clientId,
|
||||
clientName: a?.clientName,
|
||||
productName: a?.productName,
|
||||
savingsProductName: a?.savingsProductName,
|
||||
fdType: a?.fdType,
|
||||
maturityDate: toIsoDate(a?.maturityDate),
|
||||
depositAmount: a?.depositAmount,
|
||||
maturityAmount: a?.maturityAmount,
|
||||
onClosureType: a?.onClosureType,
|
||||
linkedAccountId: a?.linkedAccountId,
|
||||
renewalGracePeriodDays: a?.renewalGracePeriodDays
|
||||
};
|
||||
}
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────────────────────
|
||||
|
||||
get visibleRows(): FdMaturityRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
const stage = this.stageFilter;
|
||||
return this.rows.filter((r) => {
|
||||
if (stage) {
|
||||
if (r.stage !== stage) {
|
||||
return false;
|
||||
}
|
||||
} else if (r.stage === 'ON_TRACK') {
|
||||
// No tile selected -- the default view is the worklist, not every active FD.
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.accountNo} ${r.clientName}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get summary(): FdMaturitySummary {
|
||||
return maturitySummary(this.rows);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.stageFilter = '';
|
||||
this.search = '';
|
||||
}
|
||||
|
||||
toggleStage(stage: FdMaturityStage): void {
|
||||
this.stageFilter = this.stageFilter === stage ? '' : stage;
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
openAccount(row: FdMaturityRow): void {
|
||||
if (!row.clientId) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'fixed-deposits-accounts',
|
||||
row.id,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Renewal ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account
|
||||
* page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date
|
||||
* and gap-interest crediting decided from the grace window rather than picked here (see
|
||||
* PaymentDialogComponent's `dashboardRenewal` mode). */
|
||||
openRenewDialog(row: FdMaturityRow): void {
|
||||
if (!row.renewable || this.renewing != null) {
|
||||
return;
|
||||
}
|
||||
this.renewing = row.id;
|
||||
this.dialog
|
||||
.open(PaymentDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
data: {
|
||||
accountId: row.id,
|
||||
accountBalance: row.maturityAmount,
|
||||
maturityDate: row.maturityDate,
|
||||
dashboardRenewal: { row }
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: { confirm: boolean } | undefined) => {
|
||||
this.renewing = null;
|
||||
if (result?.confirm) {
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
stageClass(stage: FdMaturityStage): string {
|
||||
return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`;
|
||||
}
|
||||
|
||||
/** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */
|
||||
maturityText(row: FdMaturityRow): string {
|
||||
if (row.daysOverdue > 0) {
|
||||
return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`;
|
||||
}
|
||||
if (row.daysRemaining === 0) {
|
||||
return 'Matures today';
|
||||
}
|
||||
return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no
|
||||
* grace configured) -- the sub-label under a matured, renewable row's stage badge. */
|
||||
graceStatusText(row: FdMaturityRow): string {
|
||||
if (!row.renewalGracePeriodDays) {
|
||||
return 'Renewable';
|
||||
}
|
||||
const grace = row.renewalGracePeriodDays;
|
||||
if (row.withinGraceWindow) {
|
||||
const daysLeft = grace - row.daysOverdue;
|
||||
return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`;
|
||||
}
|
||||
const daysPastGrace = row.daysOverdue - grace;
|
||||
return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user